pax_global_header00006660000000000000000000000064152264235700014520gustar00rootroot0000000000000052 comment=16e557e602b0a94300f0a09256be8f014a56513f linagora-ldap-rest-16e557e/000077500000000000000000000000001522642357000155505ustar00rootroot00000000000000linagora-ldap-rest-16e557e/.dev.mk000077500000000000000000000026071522642357000167450ustar00rootroot00000000000000#!/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-16e557e/.github/000077500000000000000000000000001522642357000171105ustar00rootroot00000000000000linagora-ldap-rest-16e557e/.github/workflows/000077500000000000000000000000001522642357000211455ustar00rootroot00000000000000linagora-ldap-rest-16e557e/.github/workflows/pages.yml000066400000000000000000000025741522642357000227770ustar00rootroot00000000000000name: 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@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22.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@v5 - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 with: path: _site 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@v4 linagora-ldap-rest-16e557e/.github/workflows/release.yml000066400000000000000000000246351522642357000233220ustar00rootroot00000000000000name: 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: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22.x cache: 'npm' - name: Install dependencies run: npm ci # Build before testing: the suite imports the compiled plugins from dist/. - name: Build run: npm run build - name: Run tests run: npm test # 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@v4 - 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@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY_GHCR }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY_DOCKER }} username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata for Docker id: meta uses: docker/metadata-action@v5 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@v6 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@v4 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY_GHCR }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY_DOCKER }} username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push (arm64) uses: docker/build-push-action@v6 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@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY_GHCR }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub uses: docker/login-action@v3 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@v4 - name: Setup Helm uses: azure/setup-helm@v4 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@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 22.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, newer than the version # bundled with Node 22. - 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@v4 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. Notes are auto-generated and the published tarball is attached. release: name: Create GitHub Release needs: npm runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: write steps: - name: Download release tarball uses: actions/download-artifact@v4 with: name: npm-tarball - name: Create Release uses: softprops/action-gh-release@v2 with: generate_release_notes: true fail_on_unmatched_files: true prerelease: ${{ contains(github.ref_name, '-') }} files: ldap-rest-*.tgz linagora-ldap-rest-16e557e/.github/workflows/test.yml000066400000000000000000000033161522642357000226520ustar00rootroot00000000000000name: Test Node.js Module on: push: pull_request: permissions: contents: read jobs: test: runs-on: ubuntu-latest strategy: matrix: node-version: [22.x] steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - name: Install dependencies run: npm ci - name: Run rollup build to check typescript compilation run: npm run build - name: Run tests run: npm run test lsc-plugin-test: runs-on: ubuntu-latest name: LSC ldap-rest plugin (Java) steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Java 11 uses: actions/setup-java@v4 with: distribution: temurin java-version: '11' cache: maven # lsc-core is not on Maven Central — build it from the v2.2 tag and # install it into the local Maven repo. - name: Build and install LSC core (v2.2) run: | git clone --depth 1 --branch v2.2 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@v4 with: name: lsc-ldaprest-plugin-jar path: lsc-plugin/target/lsc-ldaprest-plugin-*-with-deps.jar if-no-files-found: error linagora-ldap-rest-16e557e/.gitignore000066400000000000000000000001551522642357000175410ustar00rootroot00000000000000CLAUDE.md .claude* CLAUDE.md coverage/ dist/ node_modules/ static/browser/ static/*.html openapi.json _site/ linagora-ldap-rest-16e557e/.mocharc-one.json000066400000000000000000000001651522642357000207160ustar00rootroot00000000000000{ "require": ["tsx/cjs", "test/setup.ts"], "extensions": ["ts", "test.ts"], "exit": true, "timeout": 30000 } linagora-ldap-rest-16e557e/.mocharc.json000066400000000000000000000002241522642357000201330ustar00rootroot00000000000000{ "require": ["tsx/cjs", "test/setup.ts"], "extensions": ["ts", "test.ts"], "spec": "test/**/*.test.ts", "exit": true, "timeout": 30000 } linagora-ldap-rest-16e557e/.prettierignore000066400000000000000000000002451522642357000206140ustar00rootroot00000000000000# Build files dist/ build/ package.json # Dependencies node_modules/ # sourcemap and minified files *.min.js *.map # Other coverage/ .nyc_output/ logs/ *.log .envlinagora-ldap-rest-16e557e/.prettierrc000066400000000000000000000002611522642357000177330ustar00rootroot00000000000000{ "semi": true, "trailingComma": "es5", "singleQuote": true, "printWidth": 80, "tabWidth": 2, "useTabs": false, "bracketSpacing": true, "arrowParens": "avoid" } linagora-ldap-rest-16e557e/.vscode/000077500000000000000000000000001522642357000171115ustar00rootroot00000000000000linagora-ldap-rest-16e557e/.vscode/settings000066400000000000000000000007101522642357000206720ustar00rootroot00000000000000{ "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-16e557e/CHANGELOG.md000066400000000000000000000501101522642357000173560ustar00rootroot00000000000000# Changelog ## 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-16e557e/CONTRIBUTING.md000066400000000000000000000365071522642357000200140ustar00rootroot00000000000000# Contributing to LDAP-Rest Thank you for your interest in contributing to LDAP-Rest! This document provides guidelines and information for developers who want to contribute to the project. ## Table of Contents - [Getting Started](#getting-started) - [Project Architecture](#project-architecture) - [Development Workflow](#development-workflow) - [Plugin Development](#plugin-development) - [Testing](#testing) - [Code Style](#code-style) - [Submitting Changes](#submitting-changes) --- ## Getting Started ### Prerequisites - Node.js 18+ and npm - An LDAP server for testing (OpenLDAP, 389 Directory Server, or Active Directory) - Git ### Initial Setup 1. **Clone the repository:** ```bash git clone https://github.com/linagora/ldap-rest.git cd ldap-rest ``` 2. **Install dependencies:** ```bash npm install ``` 3. **Set up test environment:** Create a `~/.test-env` file with your test LDAP configuration: ```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" ``` 4. **Build the project:** ```bash npm run build:dev ``` 5. **Run in development mode:** ```bash source ~/.test-env && npm run start:dev ``` --- ## Project Architecture LDAP-Rest follows a **plugin-based architecture** where functionality is organized into modular, reusable plugins. ### Directory Structure ``` ldap-rest/ ├── src/ │ ├── abstract/ # Abstract base classes │ │ ├── plugin.ts # Base plugin class │ │ └── ldapFlat.ts # Base class for flat LDAP resources │ ├── bin/ # Main server entry point │ │ └── index.ts # DM class definition │ ├── browser/ # Browser libraries (TypeScript source) │ │ ├── ldap-tree-viewer/ │ │ └── ldap-user-editor/ │ ├── config/ # Configuration management │ │ ├── args.ts # CLI arguments and Config interface │ │ └── schema.ts # Schema TypeScript types │ ├── hooks.ts # Hook definitions and types │ ├── lib/ # Utility libraries │ │ ├── ldapActions.ts # LDAP operations wrapper │ │ ├── parseConfig.ts # Configuration parser │ │ └── utils.ts # Utility functions │ ├── logger/ # Logging configuration │ └── plugins/ # Core plugins │ ├── priority.json # Plugin loading order │ ├── auth/ # Authentication plugins │ ├── ldap/ # LDAP management plugins │ ├── twake/ # Twake integration plugins │ ├── configApi.ts # Configuration API │ ├── static.ts # Static file server │ └── weblogs.ts # Request logging ├── static/ │ ├── browser/ # Built browser libraries (JS/CSS) │ └── schemas/ # JSON schemas │ ├── standard/ # Standard LDAP schemas │ ├── twake/ # Twake-specific schemas │ └── ad/ # Active Directory schemas ├── test/ # Test files ├── docs/ # Documentation └── examples/ # Example applications ``` ### Core Concepts #### 1. Plugins Plugins are the building blocks of LDAP-Rest. Each plugin: - Extends `DmPlugin` abstract class - Has a unique `name` property - Can expose REST API endpoints via `api()` method - Can register hooks via `hooks` property - Can depend on other plugins via `dependencies` property - Can define roles for categorization (`auth`, `api`, `consistency`, etc.) #### 2. Hooks Hooks enable plugins to intercept and modify LDAP operations. Common hooks: - `ldapaddrequest` / `ldapadddone` - Before/after adding entries - `ldapmodifyrequest` / `ldapmodifydone` - Before/after modifying entries - `ldapdeleterequest` / `ldapdeletedone` - Before/after deleting entries - `ldapsearchrequest` - Before searching - `onLdapChange` - Any LDAP change detected See [HOOKS.md](./HOOKS.md) for complete documentation. #### 3. Configuration Configuration follows a priority order: 1. **Default values** - Defined in `src/config/args.ts` 2. **Environment variables** - Prefixed with `DM_` 3. **Command-line arguments** - Use `--option-name` format The parser accepts unknown options and stores them in config for custom plugins. #### 4. Schemas JSON schemas define the structure and validation rules for LDAP entities. They include: - Entity metadata (objectClass, mainAttribute, base DN) - Attribute definitions with types and validation - Semantic roles (identifier, displayName, primaryEmail) - UI hints for browser libraries --- ## Development Workflow ### Running the Development Server Load all available plugins automatically: ```bash source ~/.test-env && npm run start:dev ``` This runs the server with hot-reload enabled via the `.dev.mk` script. ### Building ```bash # Development build npm run build:dev # Production build (includes browser libraries) npm run build # Watch mode for development npm run build:watch # Browser libraries only npm run build:browser ``` ### Code Quality ```bash # Run linter npm run lint # Fix linting issues npm run lint:fix # Check formatting npm run format:check # Fix formatting npm run format:fix # Run both lint and format checks npm run check # Fix all issues npm run fix ``` --- ## Plugin Development ### Creating a New Plugin #### 1. Core Plugin (inside the repository) Create a file in `src/plugins//.ts`: ```typescript /** * @module plugins// * @author Your Name * * Brief description of what this plugin does */ import type { Express, Request, Response } from 'express'; import DmPlugin, { type Role } from '../../abstract/plugin'; import type { Hooks } from '../../hooks'; export default class MyPlugin extends DmPlugin { name = 'myPlugin'; roles: Role[] = ['api'] as const; // Optional: declare dependencies dependencies = { requiredPlugin: 'core/ldap/groups', }; // Constructor - initialize your plugin constructor(server: DM) { super(server); // Access configuration if (!this.config.my_plugin_option) { throw new Error('Missing --my-plugin-option'); } this.logger.info('MyPlugin initialized'); } // Optional: expose REST API endpoints api(app: Express): void { app.get(`${this.config.api_prefix}/v1/my-endpoint`, async (req, res) => { try { const result = await this.doSomething(); res.json(result); } catch (error) { res.status(500).send(error.message); } }); } // Optional: register hooks hooks: Hooks = { ldapaddrequest: async ([dn, entry, req]) => { // Validate or modify entry before adding this.logger.debug(`Adding entry: ${dn}`); return [dn, entry, req]; }, ldapadddone: async ([dn, success]) => { // React to successful addition if (success) { this.logger.info(`Entry added: ${dn}`); } return [dn, success]; }, }; // Your plugin methods private async doSomething() { // Use LDAP operations const entries = await this.server.ldap.search( { paged: false, filter: '(objectClass=person)' }, this.config.ldap_base ); return entries; } } ``` #### 2. External Plugin (outside the repository) Create a file anywhere on your system, e.g., `/path/to/my-plugin.ts`: ```typescript import type { Express } from 'express'; import DmPlugin from 'ldap-rest/plugin'; // If published to npm // Or: import DmPlugin from './node_modules/ldap-rest/dist/abstract/plugin.js'; export default class ExternalPlugin extends DmPlugin { name = 'externalPlugin'; api(app: Express): void { app.get('/external', (req, res) => { res.json({ message: 'External plugin loaded!' }); }); } } ``` Load it with: ```bash npx ldap-rest --plugin /path/to/my-plugin.ts ``` #### 3. Adding Custom Configuration Options Your plugin can accept custom command-line options: ```typescript export default class MyPlugin extends DmPlugin { name = 'myPlugin'; constructor(server: DM) { super(server); // Access custom options // --my-custom-option becomes config.my_custom_option const customValue = this.config.my_custom_option; const customArray = this.config.my_custom_values; // From --my-custom-value (multiple) this.logger.info(`Custom option: ${customValue}`); } } ``` Load with: ```bash npx ldap-rest \ --plugin ./my-plugin.ts \ --my-custom-option "value" \ --my-custom-value "value1" \ --my-custom-value "value2" ``` **Note:** Unknown options are automatically parsed and stored in `config`: - `--option-name value` becomes `config.option_name = "value"` - Multiple `--option value` become `config.option = ["value1", "value2"]` - Environment variable format: `DM_OPTION_NAME` ### Plugin Best Practices 1. **Use `api_prefix`**: Always prefix API endpoints with `this.config.api_prefix` 2. **Error handling**: Wrap operations in try-catch and provide meaningful errors 3. **Logging**: Use `this.logger` with appropriate levels (debug, info, warn, error) 4. **Validation**: Validate configuration in constructor 5. **Dependencies**: Declare plugin dependencies explicitly 6. **Hooks**: Document hooks in HOOKS.md 7. **Testing**: Write tests for your plugin (see Testing section) 8. **TypeScript**: Use proper types, avoid `any` ### Using LDAP Operations The `this.server.ldap` object provides LDAP operations: ```typescript // Search const results = await this.server.ldap.search( { paged: false, scope: 'sub', // 'base', 'one', 'sub' filter: '(uid=john)', attributes: ['cn', 'mail'], }, 'ou=users,dc=example,dc=com' ); // Add await this.server.ldap.add('uid=john,ou=users,dc=example,dc=com', { objectClass: ['inetOrgPerson'], uid: 'john', cn: 'John Doe', sn: 'Doe', }); // Modify await this.server.ldap.modify('uid=john,ou=users,dc=example,dc=com', { replace: { mail: 'john@example.com' }, add: { telephoneNumber: '+1234567890' }, }); // Delete await this.server.ldap.delete('uid=john,ou=users,dc=example,dc=com'); ``` ### Plugin Loading Order Some plugins must load before others. 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. Authentication plugins should load before others. --- ## Testing ### Running Tests ```bash # All tests source ~/.test-env && npm test # Development mode (with watch) source ~/.test-env && npm run test:dev # Single test file source ~/.test-env && npm run test:one test/plugins/ldap/groups.test.ts ``` ### Writing Tests Create test files in `test/` directory: ```typescript import { describe, it, before, after } from 'mocha'; import { expect } from 'chai'; import request from 'supertest'; import { DM } from '../src/bin/index'; import { resetLdap } from './helpers/ldap'; describe('MyPlugin', () => { let server: DM; before(async () => { // Reset LDAP to known state await resetLdap(); // Create server with your plugin server = new DM(); process.argv = [ 'node', 'test', '--plugin', 'core/myPlugin', '--my-plugin-option', 'value', ]; await server.ready; await server.run(); }); after(async () => { if (server.server) { await new Promise(resolve => server.server!.close(resolve)); } }); it('should respond to /api/v1/my-endpoint', async () => { const response = await request(server.app) .get('/api/v1/my-endpoint') .expect(200); expect(response.body).to.have.property('data'); }); }); ``` ### Test Helpers Use helpers from `test/helpers/`: - `ldap.ts` - LDAP setup/reset functions - `server.ts` - Server creation utilities --- ## Code Style ### TypeScript Guidelines - **Strict typing**: Enable all strict TypeScript checks - **No `any`**: Use proper types or `unknown` - **Interfaces**: Define clear interfaces for data structures - **Async/await**: Prefer over promises and callbacks - **Error handling**: Always handle errors explicitly ### ESLint & Prettier Configuration is already set up. Run: ```bash npm run fix ``` This runs both ESLint and Prettier fixes. ### Naming Conventions - **Files**: camelCase for modules, PascalCase for classes - **Classes**: PascalCase (e.g., `MyPlugin`) - **Methods**: camelCase (e.g., `doSomething()`) - **Constants**: UPPER_SNAKE_CASE - **Interfaces**: PascalCase (e.g., `Config`) - **Types**: PascalCase (e.g., `Role`) ### Documentation - Add JSDoc comments to public methods - Include `@param` and `@returns` tags - Document module purpose at file top - Update HOOKS.md when adding new hooks Example: ```typescript /** * Validates user entry before LDAP addition * @param dn - Distinguished Name of the entry * @param entry - LDAP entry attributes * @returns Modified entry or throws error if invalid */ private validateUser(dn: string, entry: AttributesList): AttributesList { // ... } ``` --- ## Submitting Changes ### Git Workflow 1. **Fork the repository** (if external contributor) 2. **Create a feature branch:** ```bash git checkout -b feature/my-new-feature ``` or ```bash git checkout -b fix/bug-description ``` 3. **Make your changes:** - Write code - Add tests - Update documentation 4. **Run quality checks:** ```bash npm run check npm test ``` 5. **Commit your changes:** ```bash git add . git commit -m "feat: add new feature" ``` Follow [Conventional Commits](https://www.conventionalcommits.org/): - `feat:` - New feature - `fix:` - Bug fix - `docs:` - Documentation changes - `style:` - Code style changes (formatting) - `refactor:` - Code refactoring - `test:` - Adding or updating tests - `chore:` - Maintenance tasks 6. **Push to your fork:** ```bash git push origin feature/my-new-feature ``` 7. **Create a Pull Request** on GitHub ### Pull Request Guidelines - **Title**: Clear, descriptive title following Conventional Commits - **Description**: Explain what and why, not how - **Tests**: Include tests for new features - **Documentation**: Update relevant docs - **Changelog**: Note breaking changes - **Review**: Be responsive to feedback ### Code Review Process 1. Automated checks run (lint, test, build) 2. Maintainers review code 3. Feedback addressed 4. Approved and merged --- ## Additional Resources ### Documentation - [Developer Guide](./docs/DEVELOPER_GUIDE.md) - For application developers using LDAP-Rest APIs - [Plugin README](./src/plugins/README.md) - Plugin development guide - [Hooks Documentation](./HOOKS.md) - Available hooks and their usage - [Schema Guide](./docs/schemas/) - JSON schema documentation ### Examples - [Example Plugins](./src/plugins/demo/) - Demo plugins - [Example Applications](./examples/) - Web applications using LDAP-Rest - [Test Files](./test/) - Test examples ### Communication - **Issues**: [GitHub Issues](https://github.com/linagora/ldap-rest/issues) - **Discussions**: [GitHub Discussions](https://github.com/linagora/ldap-rest/discussions) --- ## License By contributing to LDAP-Rest, you agree that your contributions will be licensed under the [AGPL-3.0 License](./LICENSE). --- Thank you for contributing to LDAP-Rest! 🎉 linagora-ldap-rest-16e557e/Dockerfile000066400000000000000000000146611522642357000175520ustar00rootroot00000000000000## This Dockerfile is auto-generated by scripts/buildDockerfile.ts FROM node:24-alpine as builder RUN apk update && apk upgrade 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 && rm -rf /app-build && npm cache clean --force ENV NODE_ENV=production \ DM_PORT="8081" \ DM_PLUGINS=core/static,core/helloworld \ DM_LOG_LEVEL="notice" \ DM_LOGGER="console" \ DM_API_PREFIX="/api" \ DM_MAIL_DOMAIN= \ DM_LDAP_BASE= \ DM_LDAP_DN="cn=admin,dc=example,dc=com" \ DM_LDAP_PWD="admin" \ DM_LDAP_URL=ldap://localhost \ DM_LDAP_USER_ATTRIBUTE="uid" \ DM_LDAP_CACHE_MAX="1000" \ DM_LDAP_CACHE_TTL="300" \ 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="mailQuota" \ 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_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_LDAP_FLAT_SCHEMA= \ DM_BULK_IMPORT_SCHEMAS= \ DM_BULK_IMPORT_MAX_FILE_SIZE="10485760" \ DM_BULK_IMPORT_BATCH_SIZE="100" \ DM_JAMES_WEBADMIN_URL="http://localhost:8000" \ DM_JAMES_WEBADMIN_TOKEN= \ DM_JAMES_SIGNATURE_TEMPLATE= \ DM_LDAP_CONCURRENCY="10" \ DM_JAMES_CONCURRENCY="10" \ DM_JAMES_INIT_DELAY="1000" \ DM_JAMES_MAILING_LIST_BRANCHES= \ DM_JAMES_MAILBOX_TYPE_ATTRIBUTE="twakeMailboxType" \ 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= \ 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_RABBITMQ_URL= \ DM_APPLICATIVE_ACCOUNT_BASE= \ DM_MAX_APP_ACCOUNTS="5" \ 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_AUTH_TOKENS= \ 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_PER_BRANCH_CONFIG="{\"default\":{\"read\":true,\"write\":false,\"delete\":false}}" \ DM_AUTHZ_PER_BRANCH_CACHE_TTL="60" \ 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_OIDC_SERVER= \ DM_OIDC_CLIENT_ID= \ DM_OIDC_CLIENT_SECRET= \ DM_BASE_URL= \ 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_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_GROUP_OBJECT_CLASSES=top,groupOfNames \ DM_SCIM_GROUP_RDN_ATTRIBUTE="cn" \ DM_SCIM_ID_ATTRIBUTE="rdn" \ DM_SCIM_USER_MAPPING= \ DM_SCIM_GROUP_MAPPING= \ DM_SCIM_MAX_RESULTS="200" \ 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 CMD ["npx", "ldap-rest"] linagora-ldap-rest-16e557e/LICENSE000066400000000000000000001033321522642357000165570ustar00rootroot00000000000000 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-16e557e/README.md000066400000000000000000000100061522642357000170240ustar00rootroot00000000000000[![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 3 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) ## 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-16e557e/SECURITY.md000066400000000000000000000104531522642357000173440ustar00rootroot00000000000000# 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-16e557e/bin/000077500000000000000000000000001522642357000163205ustar00rootroot00000000000000linagora-ldap-rest-16e557e/bin/cleanup-external-users.mjs000077500000000000000000000133271522642357000234520ustar00rootroot00000000000000#!/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-16e557e/bin/index.mjs000077500000000000000000000030131522642357000201420ustar00rootroot00000000000000#!/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 --ldap-pwd PASSWORD LDAP bind password --ldap-base BASE LDAP base DN --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 server with default configuration npx ldap-rest # Start with specific LDAP server npx ldap-rest --ldap-url ldap://localhost:389 # Load specific plugins npx ldap-rest --plugin ldapGroups --plugin ldapOrganization `); process.exit(0); } const server = new DM(); await server.ready; await server.run(); linagora-ldap-rest-16e557e/bin/sync-app-accounts.mjs000066400000000000000000000233331522642357000224060ustar00rootroot00000000000000#!/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-16e557e/bin/sync-james.mjs000077500000000000000000000257561522642357000211260ustar00rootroot00000000000000#!/usr/bin/env node /** * Sync utility to verify and fix consistency between LDAP and James * Ensures James quotas match LDAP mailQuota values and that James aliases * match the LDAP alias attribute (catch-up in case the event-based sync failed) * @author Generated with Claude Code */ import fetch from 'node-fetch'; 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 mailQuota) - 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: mailQuota) 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; } /** * 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 || 'mailQuota'; 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}: ${err.message}`); 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-16e557e/docker-compose.swarm.example.yml000066400000000000000000000031021522642357000237630ustar00rootroot00000000000000version: '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_MAX: '1000' DM_LDAP_CACHE_TTL: '300' 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-16e557e/docs/000077500000000000000000000000001522642357000165005ustar00rootroot00000000000000linagora-ldap-rest-16e557e/docs/README.md000066400000000000000000000035661522642357000177710ustar00rootroot00000000000000# 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 - **[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 - **[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 ## 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-16e557e/docs/client-development/000077500000000000000000000000001522642357000222765ustar00rootroot00000000000000linagora-ldap-rest-16e557e/docs/client-development/README.md000066400000000000000000000212351522642357000235600ustar00rootroot00000000000000# 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-16e557e/docs/client-development/api/000077500000000000000000000000001522642357000230475ustar00rootroot00000000000000linagora-ldap-rest-16e557e/docs/client-development/api/README.md000066400000000000000000000034151522642357000243310ustar00rootroot00000000000000# 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-16e557e/docs/client-development/api/openapi.md000066400000000000000000000235501522642357000250310ustar00rootroot00000000000000# 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'], ['calendarResources', 'Calendar Resources'], ['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-16e557e/docs/client-development/api/reference.md000066400000000000000000000536051522642357000253400ustar00rootroot00000000000000# 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:** ```json { "error": "LDAP bind error" } ``` --- ## 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:** ```json { "error": "LDAP bind error" } ``` **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\": must match ^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$" } ``` **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-16e557e/docs/client-development/api/rest-api.md000066400000000000000000000610171522642357000251220ustar00rootroot00000000000000# 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", "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" } } } } ``` **Response Fields:** - `apiPrefix`: API URL prefix (default: `/api`) - `ldapBase`: LDAP base DN - `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) --- ## 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" } ] ``` ### 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 **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 **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 } ``` ### 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 | | 500 | Internal Server Error | ### Error Response Format All errors return a JSON object with an error message: ```json { "error": "Description of the error" } ``` ### Common Errors #### 400 Bad Request **Missing Required Fields:** ```json { "error": "Attribute \"sn\" is required" } ``` **Invalid Attribute Format:** ```json { "error": "Invalid value for attribute \"uid\": must match ^[a-zA-Z0-9._-]{1,255}$" } ``` **Invalid LDAP Filter:** ```json { "error": "Invalid match query" } ``` #### 401 Unauthorized **Missing Authentication:** ```json { "error": "Unauthorized" } ``` **Invalid Token:** ```json { "error": "Unauthorized" } ``` #### 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" } ``` #### 409 Conflict **Duplicate Entry:** ```json { "error": "Failed to add user uid=john.doe,ou=users,dc=example,dc=com: Entry Already Exists" } ``` #### 500 Internal Server Error **LDAP Connection Error:** ```json { "error": "LDAP bind error" } ``` **General Server Error:** ```json { "error": "Internal server error:
" } ``` --- ## 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 ``` ### 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 ### Pagination For large result sets, consider implementing client-side pagination or use LDAP paging (controlled server-side). ### 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-16e557e/docs/client-development/browser/000077500000000000000000000000001522642357000237615ustar00rootroot00000000000000linagora-ldap-rest-16e557e/docs/client-development/browser/README.md000066400000000000000000000034321522642357000252420ustar00rootroot00000000000000# 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-16e557e/docs/client-development/browser/libraries.md000066400000000000000000000730201522642357000262610ustar00rootroot00000000000000# 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) --- ## Overview LDAP-Rest provides three 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 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. --- ## 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-16e557e/docs/client-development/examples/000077500000000000000000000000001522642357000241145ustar00rootroot00000000000000linagora-ldap-rest-16e557e/docs/client-development/examples/README.md000066400000000000000000001170761522642357000254070ustar00rootroot00000000000000# 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-16e557e/docs/client-development/examples/ldif/000077500000000000000000000000001522642357000250325ustar00rootroot00000000000000twake-mailbox-type-nomenclature.ldif000066400000000000000000000023241522642357000340310ustar00rootroot00000000000000linagora-ldap-rest-16e557e/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.ldif000066400000000000000000000025431522642357000345460ustar00rootroot00000000000000linagora-ldap-rest-16e557e/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-16e557e/docs/client-development/schemas/000077500000000000000000000000001522642357000237215ustar00rootroot00000000000000linagora-ldap-rest-16e557e/docs/client-development/schemas/README.md000066400000000000000000000743741522642357000252170ustar00rootroot00000000000000# 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) --- ## 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`. ### 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 | Attribute data type: `"string"`, `"array"`, `"pointer"`, `"number"`, `"integer"` | `"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 | Semantic role 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}$"` | | `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) | `{}` | | `group` | string | All | UI grouping for form organization (future feature) | `"contact"` | | `default` | any | All | Default value for new entries | `["top", "person"]` | | `items` | object | Array | Schema for array element validation | `{"type": "string", "test": "..."}` | ### 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 #### `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). ```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. ```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]+$" } } } ``` #### `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]+$ ``` **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 match their declared type: - **String**: Single text value - **Array**: Multiple values (may be empty unless required) - **Number**: Numeric value (integer or float) - **Integer**: Integer value only - **Pointer**: Valid DN string ### Fixed Attributes Attributes with `"fixed": true` cannot be modified after creation. ```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' \ --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' \ --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' \ --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' \ --plugin core/ldap/flatGeneric \ --ldap-flat-schema ./path/to/custom-users.json ``` --- ## 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-16e557e/docs/linagora.png000066400000000000000000000105711522642357000210060ustar00rootroot00000000000000PNG  IHDR)p@fsRGB,gAMA a cHRMz&u0`:pQ< pHYs.#.#x?vtIME ;0\IDATx]iXTG~{ihfQPDwQh\Fܒܣ1shFtƸ&7q7ьf &5(jehz>EnV:[S E$ @8K5tGO5>+`GwI>ڤ5teșcX8$wO} iq. :+B>۴j%cc(8I OfDZtTDl̓!A|DZ&~B.8!X6Qp 0CXpp0+(#C.Х=,a$ #3֨z]\(Do&MnTeveX]C|#@ֹU=5E-RҞ;* poc)=}}n٬1pb .łY^JT޾'@ g?"arB32&L*Ԝl_ , (Y{M* =^;z9FݐmI==isA+he~ǽs&7 ˒տm>+C(p.3?3K_LO˭_ Us7 IP$AP ް k̠SeMݔM^ʯBgaV7xMN2ށ鑪aL]a6#L&\;B8kړ-#NߐXFp0 #齐fsB }/#Zmer Q}i]mrqo"̗pFWF.=>a 37P/q-|q|oy:- ĹE}J~4Efnţ˚$B؂$mY*vHy&hڄ5{8s@ wԕ[uJ'/u<{E/ږʷLPr k7t08B/ y/ 7]̖PZ0/DJ҈P:@|F {ua\gԥ{u(3yf Lu_=f$VK{&ɫЮpN^5JT"Fp ys6xm7*??C'#e:۸- s77,EMQ1 '/Oxl{(QtGݭ' b֩Zfwڄ$[6rOie|xE&?iϘdq("}1z 7smS(qf@'Ic4~vTl?m3B j]M5~wF==| fFҶشpNT^?MϙNUJDSfZ{pz>]B:T݀07AdEE.rxoUaG^dj˃U yhdY](|탸GEf%t=ذ ,3~!433W74T>ĶJQ%tho)P{OD_='m 4SA@q}Qmy|}%A<\Te>)Ľ(յ[BIE=)`iZ}W(G_vq3 Tk t@E% ;PWFq&7[FXE:sľU 5V ďhV^(wL_bNߣRdnzxpJ*z|(TI밸zZrkQPaT|جcP!fE٧_Y#NX:K.Ӯm daf|rI@}!yjp Әc<2eNHЂ$M :E}5hQKn$.rZ.tHLP<{hLgq89 }|pZYm6 ^ {dWv6)O^t \Q(۔B|_}N= kx6'\lsH;a b% /\3A2jՖ/m-^{#WUcQr0 ~$eݏwvEm{,.'ށaG{ eS(^YW6W2Z?"/O$iO,G7jSWp6IFY}'[(sbH CdjۥX/:VkzR֢}ٯg/eWo븅Ax]n:'Y(E@7n F佨f硱m t kNoouyv]9@!}w]ү2PS~ !̈́)>iހMrn,h1Γ>@ĥ-ݘ~)-J#hSh>>K6+QpJyb3xdg7|7̓2+Av形~~V8A2XS[(BcMC=0_ɠ4@ݿѭS&3`7SVP憀"{ږӡPOxDOB,[K)>B[,np%:~ ol QWZ LaC$C |f.BW'dJ!(XYc:-:\1sdM~5ӛl_GP]1(,LK 䓆Fk7[@$™p,=+~0)h{Q,l;%cz䕖qySы}?[mrh{na!݅L d;o G4k%(I`e|).5|Uhjhr”ٌzz4~WibMD01ɶ 7|Ӑ%gۙSyk@_ZM$A^a3u_3:=߿mܞ0¸g5mT)VV$h3ZU#ŦJ{` ]f,b{QI 7}JV)W,OHlhG$"bbՅXǖLo*LU䙀|6c@\EA^79t)K2dM\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 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 LDAP-Rest automatically accepts and stores any unknown command-line options in the config object. This allows your plugin to define custom configuration without modifying core files. ### How It Works 1. **Command-line option format**: `--option-name value` 2. **Converted to config key**: `option_name` (dashes become underscores) 3. **Environment variable format**: `DM_OPTION_NAME` (uppercase with underscores) 4. **Access in plugin**: `this.config.option_name` ### Single Value Options ```bash # Command line npx ldap-rest \ --webhook-url https://example.com/webhook \ --webhook-timeout 5000 \ --webhook-retry true # Environment variables export DM_WEBHOOK_URL=https://example.com/webhook export DM_WEBHOOK_TIMEOUT=5000 export DM_WEBHOOK_RETRY=true ``` In your plugin: ```typescript constructor(server: DM) { super(server); // Access custom options (converted to snake_case) const url = this.config.webhook_url; // string const timeout = this.config.webhook_timeout; // string (convert if needed) const retry = this.config.webhook_retry; // string 'true' (convert if needed) // Type conversion const timeoutNum = parseInt(timeout as string || '3000'); const retryBool = (retry as string) === 'true'; } ``` ### Multiple Value Options Repeat the same option multiple times to create an array: ```bash # Command line npx ldap-rest \ --allowed-domain example.com \ --allowed-domain test.com \ --allowed-domain dev.com # Environment variable (comma or semicolon separated) export DM_ALLOWED_DOMAINS=example.com,test.com,dev.com ``` In your plugin: ```typescript constructor(server: DM) { super(server); // Access as array (note: singular becomes plural in config key) const domains = this.config.allowed_domain as string[]; // or sometimes stored as: this.config.allowed_domains this.logger.info(`Allowed domains: ${domains.join(', ')}`); } ``` ### Configuration Priority Values are resolved in this order (later overrides earlier): 1. **Default value** in your plugin 2. **Environment variable** (`DM_OPTION_NAME`) 3. **Command-line argument** (`--option-name`) ```typescript constructor(server: DM) { super(server); // Provide defaults for optional configuration const webhookUrl = this.config.webhook_url || 'http://localhost:8080/webhook'; const webhookTimeout = parseInt(this.config.webhook_timeout as string || '3000'); const maxRetries = parseInt(this.config.max_retries as string || '3'); // Validate required configuration if (!this.config.webhook_secret) { throw new Error('--webhook-secret is required for authentication'); } } ``` --- ## 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: #### 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) 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', }, }); ``` #### 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 Create REST API endpoints for your plugin. **Important:** Always use the `asyncHandler` wrapper for async routes to ensure errors are properly handled and don't 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'); } ``` #### 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); }) ); ``` **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 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 ```bash # Set up test environment cat > ~/.test-env <|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. `calendarResources` - Calendar sync (optional) ### 5. Utility Plugins (can load anytime) - `static` - Static files - `weblogs` - Logging - `configApi` - Configuration API ## Plugin Priority System LDAP-Rest uses a priority system defined in `src/plugins/priority.json`: ```json [ "core/auth/crowdsec", "core/auth/rateLimit", "core/auth/token", "core/auth/llng", "core/auth/openidconnect", "core/auth/authzPerBranch", "core/auth/authzLinid1" ] ``` Plugins in this list load before others, ensuring security plugins run first. ## Hook Execution Order Hooks are executed in the order plugins are loaded. 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 non-chained hooks: - All hooks execute in parallel - Order doesn't matter **Example**: `ldapdeleterequest` chain ``` Request → 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 ```typescript export default class MyPlugin extends DmPlugin { async someMethod() { // Fire a hook for other plugins to consume await this.server.launchHooks('myCustomHook', [param1, param2]); } } ``` ## 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-16e557e/docs/plugin-development/hooks.md000066400000000000000000000011601522642357000237610ustar00rootroot00000000000000# Core hooks Set here the documentation of all hooks. Typescript definitions into [hooks.ts](./src/hooks.ts) ## Demo hooks - **hello**: called by [helloworld demo plugin](./src/plugins/helloworld.ts) ## [LDAP](./src/lib/ldapActions.ts) hooks - **ldapopts**: called before any ldapsearch to modify search options - **ldapsearchresult**: called after any ldapsearch to modify search result - **ldapaddrequest**: called before any ldapadd - **ldapmodifyrequest**: called before any ldapmodify - **ldapdeleterequest**: called before any ldapdelete - **ldaprenamerequest**: called before any ldap rename/modifyDN operation linagora-ldap-rest-16e557e/docs/plugin-development/openapi.md000066400000000000000000000134101522642357000242720ustar00rootroot00000000000000# 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. 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/ ``` 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-16e557e/docs/plugin-development/testing.md000066400000000000000000000213601522642357000243170ustar00rootroot00000000000000# 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 **Using embedded LDAP (no setup required):** ```bash npm test npm run test:one test/plugins/ldap/flatGeneric.test.ts ``` **Using external LDAP:** ```bash export DM_LDAP_URL=ldap://localhost:389 export DM_LDAP_DN=cn=admin,dc=example,dc=com export DM_LDAP_PWD=secret export DM_LDAP_BASE=dc=example,dc=com npm test ``` ### In CI/CD **GitHub Actions example:** ```yaml name: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '20' - 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-16e557e/docs/presentations/000077500000000000000000000000001522642357000213765ustar00rootroot00000000000000linagora-ldap-rest-16e557e/docs/presentations/en.md000066400000000000000000000365131522642357000223320ustar00rootroot00000000000000--- 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/calendarResources** - Calendar Resources # 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 - 👥 **Mailing Lists** - LDAP Groups → Address Groups - 📨 **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: mailQuota = 1000000000 → 5000000000 (1GB → 5GB) ↓ onChange detects the change ↓ Hook onLdapQuotaChange triggered ↓ James WebAdmin: PUT /quota/users/alice@.../size → Quota updated immediately ✅ CONSISTENCY GUARANTEED ``` # James Plugin - List Consistency ## LDAP Groups → James Address Groups ```bash # Create a group with mail attribute POST /api/v1/ldap/groups { "cn": "engineering", "mail": "engineering@company.com", "member": ["uid=alice,...", "uid=bob,..."] } ``` ### Automatic List Consistency 1. ✅ **Creation** → Group created in James + members added 2. ✅ **Add Member** → Member added to James list 3. ✅ **Remove Member** → Member removed from James list 4. ✅ **Delete Group** → List deleted in James ### Guarantee **LDAP is the source of truth, James stays synchronized** # 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-abstract'; 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 mailQuota \ --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-16e557e/docs/presentations/fr.md000066400000000000000000000403711522642357000223340ustar00rootroot00000000000000--- 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/calendarResources** - Ressources calendrier # 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 - 👥 **Listes de diffusion** - Groupes LDAP → Address Groups - 📨 **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: mailQuota = 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 ``` # Plugin James - Cohérence des listes ## Groupes LDAP → James Address Groups ```bash # Création d'un groupe avec attribut mail POST /api/v1/ldap/groups { "cn": "engineering", "mail": "engineering@company.com", "member": ["uid=alice,...", "uid=bob,..."] } ``` ### Cohérence automatique des listes 1. ✅ **Création** → Groupe créé dans James + membres ajoutés 2. ✅ **Ajout membre** → Membre ajouté à la liste James 3. ✅ **Retrait membre** → Membre retiré de la liste James 4. ✅ **Suppression groupe** → Liste supprimée dans James ### Garantie **LDAP est la source de vérité, James reste synchronisé** # 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-abstract'; 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 mailQuota \ --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-16e557e/docs/usage/000077500000000000000000000000001522642357000176045ustar00rootroot00000000000000linagora-ldap-rest-16e557e/docs/usage/README.md000066400000000000000000000036521522642357000210710ustar00rootroot00000000000000# 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 - **[Troubleshooting](troubleshooting.md)** - Problem resolution linagora-ldap-rest-16e557e/docs/usage/configuration.md000066400000000000000000000573761522642357000230170ustar00rootroot00000000000000# 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) - [Authorization Plugins](#authorization-plugins) - [core/auth/authzPerBranch](#coreauthauthzperbranch) - [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/calendarResources](#coretwakecalendarresources) - [core/twake/applicativeAccounts](#coretwakeapplicativeaccounts) - [core/twake/appAccountsConsistency](#coretwakeappaccountsconsistency) - [Utility Plugins](#utility-plugins) - [core/static](#corestatic) - [core/weblogs](#coreweblogs) - [core/configApi](#coreconfigapi) - [Configuration File](#configuration-file) - [LDAP Failover](#ldap-failover) - [Log Levels (`--log-level`)](#log-levels---log-level) ## 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:** ```bash ldap-rest --plugin core/auth/token --plugin core/ldap/flatGeneric --plugin core/ldap/groups ``` **Via CLI - use plural form with comma-separated values:** ```bash ldap-rest --plugins core/auth/token,core/ldap/flatGeneric,core/ldap/groups ``` **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" ``` > **Note:** If the value contains a semicolon, it will be used as the separator. Otherwise, commas are used. Whitespace around separators is ignored. ## General Options | CLI | Plural | Env | Default | Description | | ---------------- | ---------------- | ----------------- | ---------------- | ------------------------------------------- | | `--port` | | `DM_PORT` | `8081` | Listen port | | `--plugin` | `--plugins` | `DM_PLUGINS` | `[]` | Plugins to load | | `--log-level` | | `DM_LOG_LEVEL` | `notice` | Log level: error, warn, notice, info, debug | | `--logger` | | `DM_LOGGER` | `console` | Logger type | | `--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 | ## 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` | `cn=admin,dc=example,dc=com` | Bind DN | | `--ldap-pwd` | | `DM_LDAP_PWD` | `admin` | Password | | `--ldap-base` | | `DM_LDAP_BASE` | | Base DN for searches | | `--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` | `300` | Cache TTL (seconds) | | `--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 | ## Special Attributes | CLI | Env | Default | Description | | -------------------------- | --------------------------- | ----------------------- | ---------------------- | | `--mail-attribute` | `DM_MAIL_ATTRIBUTE` | `mail` | Email attribute | | `--quota-attribute` | `DM_QUOTA_ATTRIBUTE` | `mailQuota` | 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 | | `--group-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) | #### `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` | | DNs to watch for deletions | | `--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/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 (cn, givenName, sn) #### `core/ldap/departmentSync` Maintains consistency of department links when organizations are renamed/moved. No configuration options - uses [core/ldap/organizations](#coreldaporganizations) settings. ### Authentication Plugins #### `core/auth/token` | CLI | Plural | Env | Default | Description | | -------------- | --------------- | ---------------- | ------- | --------------------- | | `--auth-token` | `--auth-tokens` | `DM_AUTH_TOKENS` | `[]` | Authentication tokens | #### `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 | #### `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 | | `--base-url` | `DM_BASE_URL` | | Public URL for callbacks | ### Authorization Plugins #### `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) | #### `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 | ### 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-mailing-list-branch` | `--james-mailing-list-branches` | `DM_JAMES_MAILING_LIST_BRANCHES` | `[]` | Mailing list branches | | `--james-mailbox-type-attribute` | | `DM_JAMES_MAILBOX_TYPE_ATTRIBUTE` | `twakeMailboxType` | Mailbox type attribute | | `--ldap-concurrency` | | `DM_LDAP_CONCURRENCY` | `10` | LDAP operations concurrency | #### `core/twake/calendarResources` | 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` | | Resource creator | | `--calendar-resource-domain` | `DM_CALENDAR_RESOURCE_DOMAIN` | | Resource domain | #### `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 | | `--ldap-operational-attribute` | `--ldap-operational-attributes` | `DM_LDAP_OPERATIONAL_ATTRIBUTES` | _(see below)_ | Operational attributes to exclude | Default operational attributes: `dn`, `controls`, `structuralObjectClass`, `entryUUID`, `entryDN`, `subschemaSubentry`, `modifyTimestamp`, `modifiersName`, `createTimestamp`, `creatorsName`, `userPassword` #### `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` ### 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 | #### `core/weblogs` Web access logging plugin. No configuration options - just add the plugin to enable access logs. #### `core/configApi` Exposes API configuration at `/api/v1/config` for client applications. Returns available features, schemas, and endpoints. 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. linagora-ldap-rest-16e557e/docs/usage/plugins/000077500000000000000000000000001522642357000212655ustar00rootroot00000000000000linagora-ldap-rest-16e557e/docs/usage/plugins/README.md000066400000000000000000000072771522642357000225610ustar00rootroot00000000000000# 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 | ## 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 | ## 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 | | [calendar-resources](integrations/calendar-resources.md) | Twake calendar resources | | [app-accounts](integrations/app-accounts.md) | Applicative accounts API | ## Utility Plugins | Plugin | Description | | ------------------------------- | ------------------ | | [static](utilities/static.md) | Static file server | | [weblogs](utilities/weblogs.md) | HTTP logging | ## 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 ``` See [the complete dependencies matrix](../../plugin-development/dependencies.md). ## Load Order Authentication plugins are loaded first to secure API endpoints. The order is defined in `src/plugins/priority.json`. linagora-ldap-rest-16e557e/docs/usage/plugins/auth/000077500000000000000000000000001522642357000222265ustar00rootroot00000000000000linagora-ldap-rest-16e557e/docs/usage/plugins/auth/README.md000066400000000000000000000111031522642357000235010ustar00rootroot00000000000000# 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 | ## 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 | ## 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 | ## 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-16e557e/docs/usage/plugins/auth/authz-dynamic.md000066400000000000000000000200211522642357000253200ustar00rootroot00000000000000# 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 | ## 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. - Absent flags default to `false`. - `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. - **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-16e557e/docs/usage/plugins/auth/authz-linid1.md000066400000000000000000000347131522642357000250710ustar00rootroot00000000000000# 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. ## 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. ## 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. Verifies user has read permission for search base 6. Throws error if access denied **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 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 ### Problem: Cannot Search Top Organization **Symptoms:** ```json { "error": "User admin does not have read permission for branch ou=organization,dc=example,dc=com" } ``` **Solutions:** This is expected behavior - users can only see organizations they manage. To grant access to top organization: ```bash ldapsearch -x -b "ou=organization,dc=example,dc=com" -s base "(objectClass=*)" # Modify to add user: ldapmodify <:*` | 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 | ## 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-16e557e/docs/usage/plugins/auth/crowdsec.md000066400000000000000000000042611522642357000243640ustar00rootroot00000000000000# 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-16e557e/docs/usage/plugins/auth/hmac.md000066400000000000000000000230201522642357000234550ustar00rootroot00000000000000# 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(request_body) for POST/PATCH/PUT, empty string for GET/DELETE/HEAD 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 SHA256(JSON.stringify(body)) 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-16e557e/docs/usage/plugins/auth/llng.md000066400000000000000000000044311522642357000235060ustar00rootroot00000000000000# 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" ``` ## 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 ## 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-16e557e/docs/usage/plugins/auth/oidc.md000066400000000000000000000073421522642357000234740ustar00rootroot00000000000000# 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 ``` ## 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 - [OpenID Connect Specification](https://openid.net/connect/) - [express-openid-connect](https://github.com/auth0/express-openid-connect) linagora-ldap-rest-16e557e/docs/usage/plugins/auth/rate-limit.md000066400000000000000000000030401522642357000246140ustar00rootroot00000000000000# 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. ## 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-16e557e/docs/usage/plugins/auth/token.md000066400000000000000000000065371522642357000237030ustar00rootroot00000000000000# 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-16e557e/docs/usage/plugins/auth/totp.md000066400000000000000000000136401522642357000235420ustar00rootroot00000000000000# 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-16e557e/docs/usage/plugins/auth/trusted-proxy.md000066400000000000000000000076211522642357000254270ustar00rootroot00000000000000# 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:** Automatically handled (e.g., `::ffff:127.0.0.1` matches `127.0.0.1`) ## 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-16e557e/docs/usage/plugins/integrations/000077500000000000000000000000001522642357000237735ustar00rootroot00000000000000linagora-ldap-rest-16e557e/docs/usage/plugins/integrations/README.md000066400000000000000000000022051522642357000252510ustar00rootroot00000000000000# 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 | ## Twake | Plugin | Description | | ------------------------------------------- | ---------------------------------------- | | [calendar-resources](calendar-resources.md) | Calendar resource synchronization | | [app-accounts](app-accounts.md) | Applicative accounts API (devices, apps) | ## 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/calendarResources └─ requires: core/ldap/onChange ``` linagora-ldap-rest-16e557e/docs/usage/plugins/integrations/app-accounts.md000066400000000000000000000321711522642357000267160ustar00rootroot00000000000000# 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 - Synchronizes mail changes across all app accounts 3. **Applicative accounts base** configured in LDAP (e.g., `ou=applicative,dc=example,dc=com`) ## 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) - `--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 ### 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 ## 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 attributes from user (cn, sn, mail, etc.) - 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-16e557e/docs/usage/plugins/integrations/calendar-resources.md000066400000000000000000000123061522642357000301000ustar00rootroot00000000000000# Twake Calendar Resources Plugin Plugin to automatically synchronize LDAP resources (meeting rooms, equipment, etc.) with Twake Calendar via its WebAdmin API. ## 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 ## 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 (e.g., `ou=resources,dc=example,dc=com`) - `DM_CALENDAR_RESOURCE_OBJECTCLASS`: Optional objectClass filter (only process entries with this objectClass) - `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) ### 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 ### 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/calendarResources" ``` 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": "calendarResources", "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` ### 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. ## Public Methods Public methods available on the CalendarResources plugin instance. ### Usage ```typescript import type { DM } from 'ldap-rest'; import type CalendarResources from 'ldap-rest/plugin-twake-calendarresources'; // Get the plugin instance from another plugin const calendar = this.server.getPlugin( 'calendarResources' ) as CalendarResources; ``` ### 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 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-16e557e/docs/usage/plugins/integrations/drive.md000066400000000000000000000413021522642357000254260ustar00rootroot00000000000000# 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-16e557e/docs/usage/plugins/integrations/james-mail.md000066400000000000000000000561161522642357000263450ustar00rootroot00000000000000# 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 mailQuota \ --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: `mailQuota`) - `--alias-attribute`: LDAP attribute for email aliases (default: `mailAlternateAddress`) - `--james-mailbox-type-attribute`: LDAP attribute for mailbox type (default: `twakeMailboxType`) ## How It Works ### Mailing Lists When a group with a `mail` attribute is created or modified: 1. **Create mailing list** ```bash POST /api/v1/ldap/groups { "cn": "engineering", "mail": "engineering@company.com", "member": [ "uid=alice,ou=users,dc=example,dc=com", "uid=bob,ou=users,dc=example,dc=com" ] } ``` 2. **James plugin** creates address group and adds all members: ```http PUT /address/groups/engineering@company.com/alice@company.com PUT /address/groups/engineering@company.com/bob@company.com ``` 3. **Add/remove members** - when group members change: ```bash POST /api/v1/ldap/groups/engineering/members {"member": "uid=charlie,ou=users,dc=example,dc=com"} ``` James plugin automatically syncs: ```http PUT /address/groups/engineering@company.com/charlie@company.com ``` 4. **Delete mailing list** - when group is deleted: ```http DELETE /address/groups/engineering@company.com ``` **Notes:** - Only groups with a `mail` attribute are synchronized to James - Groups without `mail` are ignored (regular LDAP groups) - James Address Groups are simple distribution lists - All group members receive emails sent to the group address - List type attributes (open/restricted) are stored in LDAP but not enforced by James ### 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 all associated data ### Quota Changes When a user's quota changes: 1. **LDAP modify operation** ```bash PUT /api/v1/ldap/users/jdoe { "replace": { "mailQuota": "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 ``` ### 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 ``` **Note:** James automatically creates an alias from the old address when renaming a user. The plugin attempts to create this alias explicitly, but handles the 409 Conflict response gracefully if it already exists. #### 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) Update a user's JMAP identity in James (name and optional signature). **Signature:** ```typescript async updateJamesIdentity(dn: string, mail: string, displayName: 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 **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 | | Add group member | `/address/groups/{group}/{member}` | PUT | | Remove group member | `/address/groups/{group}/{member}` | DELETE | | Delete group | `/address/groups/{group}` | 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 mailQuota \ --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 mailQuota \ --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 ### 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": {"mailQuota": "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 | `"mailQuota": "1000000000"` | | 5 GB | 5000000000 | `"mailQuota": "5000000000"` | | 10 GB | 10000000000 | `"mailQuota": "10000000000"` | | Unlimited | -1 | `"mailQuota": "-1"` | ## Synchronization Scenarios ### Scenario 1: User Creation 1. Create user in LDAP with mail attribute 2. James plugin does nothing (user doesn't exist in James yet) 3. Create mail account in James manually or via provisioning script 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 ## Mailing List Examples ### Creating a Mailing List ```bash # Create a group with mail attribute curl -X POST http://localhost:8081/api/v1/ldap/groups \ -H "Authorization: Bearer admin-token" \ -H "Content-Type: application/json" \ -d '{ "cn": "engineering", "mail": "engineering@company.com", "member": [ "uid=alice,ou=users,dc=example,dc=com", "uid=bob,ou=users,dc=example,dc=com" ], "twakeDepartmentLink": "ou=organization,dc=example,dc=com", "twakeDepartmentPath": "Engineering" }' ``` The James plugin automatically creates the mailing list and adds all members. ### Adding a Member ```bash # Add member to existing group curl -X POST http://localhost:8081/api/v1/ldap/groups/engineering/members \ -H "Authorization: Bearer admin-token" \ -H "Content-Type: application/json" \ -d '{"member": "uid=charlie,ou=users,dc=example,dc=com"}' ``` James is automatically updated to include charlie@company.com in the distribution list. ### Removing a Member ```bash # Remove member from group curl -X DELETE http://localhost:8081/api/v1/ldap/groups/engineering/members/uid=alice,ou=users,dc=example,dc=com \ -H "Authorization: Bearer admin-token" ``` James removes alice@company.com from the mailing list. ### Deleting a Mailing List ```bash # Delete the entire group curl -X DELETE http://localhost:8081/api/v1/ldap/groups/engineering \ -H "Authorization: Bearer admin-token" ``` James deletes the entire address group. ## 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**: Plugin doesn't delete James accounts when LDAP users are deleted. 4. **Synchronous**: James API calls block LDAP response. Slow James = slow LDAP. 5. **No send restrictions**: James doesn't enforce list type restrictions (open/member/owner-only). These are metadata in LDAP only. ## 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 mailQuota ``` 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-16e557e/docs/usage/plugins/integrations/james-mailboxes.md000066400000000000000000000260501522642357000274000ustar00rootroot00000000000000# James Team Mailboxes This feature adds support for James/TMail team mailboxes in addition to existing mailing lists. Team mailboxes are shared mailboxes with IMAP access, as opposed to mailing lists which only distribute emails to members. ## Overview Groups in LDAP can now have three different mailbox types: - **group**: Simple group without mail functionality (default if no mail attribute) - **mailingList**: Traditional mailing list (email redistribution via James `/address/groups/` API) - **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}` ### LDAP-Rest Configuration Add to your ldap-rest configuration: ```bash # Optional: Restrict mailing lists to specific branches --james-mailing-list-branches "ou=lists,dc=example,dc=com" # Optional: Custom mailbox type attribute (default: twakeMailboxType) --james-mailbox-type-attribute "mailboxType" ``` Or via environment variables: ```bash DM_JAMES_MAILING_LIST_BRANCHES="ou=lists,dc=example,dc=com" DM_JAMES_MAILBOX_TYPE_ATTRIBUTE="mailboxType" ``` If `james-mailing-list-branches` is empty (default), mailing lists can be created anywhere. ## Usage ### Creating a Team Mailbox ```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@example.com` 2. Add each member via `PUT /domains/example.com/team-mailboxes/sales@example.com/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', }); ``` This will: 1. Validate the group is in an allowed branch (if `--james-mailing-list-branches` is configured) 2. Create the mailing list via `PUT /address/groups/announce@example.com/{member-email}` for each member ### 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). ## API Endpoints Used ### Team Mailboxes - **Create**: `PUT /domains/{domain}/team-mailboxes/{teamMailbox}` - **Add member**: `PUT /domains/{domain}/team-mailboxes/{teamMailbox}/members/{memberEmail}` - **Delete member**: `DELETE /domains/{domain}/team-mailboxes/{teamMailbox}/members/{memberEmail}` - **Delete**: `DELETE /domains/{domain}/team-mailboxes/{teamMailbox}` ### Mailing Lists - **Add member**: `PUT /address/groups/{groupMail}/{memberEmail}` - **Delete member**: `DELETE /address/groups/{groupMail}/{memberEmail}` - **Delete**: `DELETE /address/groups/{groupMail}` ## Validation Rules ### Branch Restrictions When `--james-mailing-list-branches` is configured, strict branch separation is enforced: 1. **Mailing Lists**: - ✅ **MUST** be located within one of the specified branches - ❌ **CANNOT** be created outside these branches - If `--james-mailing-list-branches` is empty, no location restriction applies 2. **Team Mailboxes**: - ✅ **MUST NOT** be located within mailing list branches - ❌ **CANNOT** be created in branches reserved for mailing lists - ✅ **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-list,ou=lists,dc=example,dc=com` with `twakeMailboxType=mailingList` ✅ - `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-list,ou=teams,dc=example,dc=com` with `twakeMailboxType=mailingList` ❌ (wrong branch) - `cn=sales-team,ou=lists,dc=example,dc=com` with `twakeMailboxType=teamMailbox` ❌ (reserved for lists) **Validation Points:** - Group creation (`ldapgroupadddone`) - Mailbox type transitions (`handleMailboxTypeTransition`) **Error Handling:** - Validation failures are logged as errors - Group is created in LDAP but James mailbox is NOT created - No exception thrown (silent failure with error log) ### Mailbox Type Defaults 3. **Mailbox Type**: - If `twakeMailboxType` is not set and the group has a `mail` attribute, it defaults to `mailingList` - If `twakeMailboxType` is not set and the group has no `mail` attribute, it's a simple group (no James integration) - Default behavior is subject to branch validation rules ## 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. ### 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. Deletes the mailing list from James (`DELETE /address/groups/{mail}`) 2. Creates the team mailbox (`PUT /domains/{domain}/team-mailboxes/{mail}`) 3. 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/{mail}/members/{member}`) 2. **Preserves the team mailbox** (does NOT delete it - can be recovered later) 3. Creates the mailing list (`PUT /address/groups/{mail}/{member}`) 4. Adds all current members to the mailing list #### 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. Creates the specified mailbox type (mailing list or team mailbox) 3. Adds all current members #### 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 mailing list: deletes it from James 2. If team mailbox: removes all members (preserves mailbox) 3. 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. ## Backward Compatibility Existing groups with `mail` attribute but no `twakeMailboxType` will continue to work as mailing lists (backward compatible behavior). ## 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 team mailbox tests npm run test:one test/plugins/twake/jamesTeamMailboxes.test.ts # Run mailbox type transition tests npm run test:one test/plugins/twake/jamesMailboxTypeTransitions.test.ts ``` ### Test Coverage - **jamesTeamMailboxes.test.ts** (4 tests): - Create team mailbox - Add member to team mailbox - Remove member from team mailbox - Delete team mailbox group (removes members, preserves mailbox) - **jamesMailboxTypeTransitions.test.ts** (7 tests): - Transition from mailingList to teamMailbox - Transition from teamMailbox to mailingList - Transition from group to teamMailbox - Transition from teamMailbox to group - Transition from group to mailingList - Transition from mailingList to group - Delete group with teamMailbox (removes members only) - **jamesBranchValidation.test.ts** (8 tests): - Allow mailing list in allowed branch - Reject mailing list outside allowed branch - Allow team mailbox outside mailing list branch - Reject team mailbox inside mailing list branch - Reject transition to mailing list outside allowed branch - Reject transition to team mailbox inside mailing list branch - Allow mailing lists anywhere when restrictions disabled - Allow team mailboxes anywhere when restrictions disabled ## 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-16e557e/docs/usage/plugins/integrations/scim.md000066400000000000000000000603771522642357000252650ustar00rootroot00000000000000# 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-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-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` | Hard cap on list results | | `--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 `/Bulk` payload size 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 | ### 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 `pwdAccountLockedTime` is set) | | `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`). ## 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`. | | `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" } } ] } ``` 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`. ### 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. ## 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=*))` | | `a eq "x" and b eq "y"` | `(&(a=x)(b=y))` | | `not (a eq "x")` | `(!(a=x))` | Unknown SCIM attributes raise `400 invalidFilter`. ## 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. ## 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 when `core/static` is loaded (editor UIs typically fetch them to render attribute pickers). - `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=…`). - `totalResults` is computed from a single LDAP search capped at `--scim-max-results`; switch to paged search for directories with many thousands of entries. linagora-ldap-rest-16e557e/docs/usage/plugins/ldap/000077500000000000000000000000001522642357000222055ustar00rootroot00000000000000linagora-ldap-rest-16e557e/docs/usage/plugins/ldap/README.md000066400000000000000000000072411522642357000234700ustar00rootroot00000000000000# 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 | | [password-policy](password-policy.md) | Password management | Expiration, Lockout, Unlock | ## 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 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 POST /api/v1/password/validate # Validate complexity (optional) ``` ### 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-16e557e/docs/usage/plugins/ldap/bulk-import.md000066400000000000000000000405061522642357000250010ustar00rootroot00000000000000# 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 ``` ### 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 and should NOT be included in the CSV: **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 / organization ``` ## 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 ### 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" } ] } ``` ## 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-16e557e/docs/usage/plugins/ldap/external-users.md000066400000000000000000000225661522642357000255230ustar00rootroot00000000000000# 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}` - **Domain Protection**: External users cannot be created if their mail are in managed domains (configured via `--mail-domain`) - **Automatic Creation**: Users are created automatically when added to a group if they don't exist ## 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 ``` ### 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 domain from `--mail-domain` 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-16e557e/docs/usage/plugins/ldap/flat-generic.md000066400000000000000000000222461522642357000250750ustar00rootroot00000000000000# 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**: LDAP objectClass values - **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. #### Fixed Attributes ```json { "objectClass": { "type": "array", "fixed": true, "default": ["top", "person"] } } ``` Fixed attributes: - Auto-provisioned with default value on creation - Cannot be modified after creation - Cannot be deleted ## Generated APIs For each schema, the plugin generates REST API endpoints: ### List Entries ```bash GET /api/v1/ldap/{pluralName}?match={filter}&attributes={attrs} ``` **Example:** ```bash curl http://localhost:8081/api/v1/ldap/users?match=uid=*smith*&attributes=uid,cn,mail ``` **Response:** ```json { "jsmith": { "dn": "uid=jsmith,ou=users,dc=example,dc=com", "uid": "jsmith", "cn": "John Smith", "mail": "jsmith@example.com" } } ``` ### 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" } ``` ### Modify Entry ```bash PUT /api/v1/ldap/{pluralName}/{id} Content-Type: application/json { "replace": { "cn": "Updated Name" }, "add": { "telephoneNumber": "+1234567890" }, "delete": ["description"] } ``` ### 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-16e557e/docs/usage/plugins/ldap/groups.md000066400000000000000000000320171522642357000240510ustar00rootroot00000000000000# 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. - **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): Filter by group name (supports wildcards and LDAP filters) - `attributes` (optional): Comma-separated list of attributes to return **Example:** ```bash curl "http://localhost:8081/api/v1/ldap/groups?match=admin*&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 - `member` can be a string (single member) or array (multiple members) - If no members provided, a dummy member is automatically added - 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 is automatically added - 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 } } } ``` ## 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-16e557e/docs/usage/plugins/ldap/on-change.md000066400000000000000000000165031522642357000243730ustar00rootroot00000000000000# LDAP onChange Plugin Monitor LDAP attribute changes and trigger hooks for specific attributes. ## Overview The `onChange` plugin watches for LDAP modifications and generates specialized hooks when configured attributes change. This enables reactive integrations with external systems (mail servers, quota managers, etc.). ## Configuration ```bash --plugin core/ldap/onChange \ --mail-attribute mail \ --quota-attribute mailQuota ``` **Environment Variables:** ```bash DM_MAIL_ATTRIBUTE="mail" DM_QUOTA_ATTRIBUTE="mailQuota" ``` ## Generated Hooks The plugin generates hooks based on configured attributes: | Config Parameter | Generated Hook | Parameters | | ------------------- | ------------------- | -------------------------------- | | `--mail-attribute` | `onLdapMailChange` | `(dn, oldMail, newMail)` | | `--quota-attribute` | `onLdapQuotaChange` | `(dn, mail, oldQuota, newQuota)` | Generic hook for any change: - `onLdapChange` - `(dn, changes)` where `changes` is `Record` ## How It Works 1. **Hooks into LDAP modify operations** via `ldapUsersmodify` and `ldapGroupsmodify` hooks 2. **Detects attribute changes** by comparing old and new values 3. **Triggers specialized hooks** for configured attributes 4. **Provides old and new values** to downstream hooks ## Use Cases ### Mail Server Synchronization When user email changes, update external mail server: ```javascript // In another plugin hooks: { onLdapMailChange: async (dn, oldMail, newMail) => { await mailServer.renameAccount(oldMail, newMail); console.log(`Renamed mail account: ${oldMail} → ${newMail}`); }; } ``` ### Quota Management When quota changes, update mail server quota: ```javascript hooks: { onLdapQuotaChange: async (dn, mail, oldQuota, newQuota) => { await mailServer.setQuota(mail, newQuota); console.log(`Updated quota for ${mail}: ${oldQuota} → ${newQuota}`); }; } ``` ### Generic Change Tracking Track all attribute changes: ```javascript hooks: { onLdapChange: async (dn, changes) => { for (const [attr, [oldVal, newVal]] of Object.entries(changes)) { console.log(`${dn}: ${attr} changed from ${oldVal} to ${newVal}`); } }; } ``` ## Examples ### Example 1: Mail Attribute Monitoring ```bash --plugin core/ldap/onChange \ --plugin core/ldap/flatGeneric \ --ldap-flat-schema ./schemas/users.json \ --mail-attribute mail ``` When a user's mail attribute changes via API: ```bash PUT /api/v1/ldap/users/jdoe { "replace": { "mail": "john.doe@newdomain.com" } } ``` The `onLdapMailChange` hook fires with: - `dn`: `"uid=jdoe,ou=users,dc=example,dc=com"` - `oldMail`: `"jdoe@olddomain.com"` - `newMail`: `"john.doe@newdomain.com"` ### Example 2: Multiple Attributes ```bash --plugin core/ldap/onChange \ --mail-attribute mail \ --quota-attribute mailQuota ``` When both attributes change: ```bash PUT /api/v1/ldap/users/jdoe { "replace": { "mail": "jdoe@company.com", "mailQuota": "5000000000" } } ``` Both hooks fire: 1. `onLdapMailChange(dn, "old@mail.com", "jdoe@company.com")` 2. `onLdapQuotaChange(dn, "jdoe@company.com", "1000000000", "5000000000")` ### Example 3: Generic Change Listener ```bash --plugin core/ldap/onChange ``` All modifications trigger `onLdapChange`: ```bash PUT /api/v1/ldap/users/jdoe { "replace": { "cn": "John A. Doe", "telephoneNumber": "+1234567890" }, "delete": ["description"] } ``` Hook receives: ```javascript { "cn": ["John Doe", "John A. Doe"], "telephoneNumber": [null, "+1234567890"], "description": ["Old description", null] } ``` ## Integration Examples ### With Twake James Mail Server ```bash --plugin core/ldap/onChange \ --plugin twake/james \ --plugin core/ldap/flatGeneric \ --ldap-flat-schema ./schemas/twake/users.json \ --mail-attribute mail \ --quota-attribute mailQuota \ --james-webadmin-url http://james:8000 ``` The James plugin listens to `onLdapMailChange` and `onLdapQuotaChange` hooks and automatically updates the James mail server. ### Custom Integration Plugin Create a custom plugin that reacts to changes: ```typescript import DmPlugin from '../abstract/plugin'; import { Hooks } from '../hooks'; export default class CustomSync extends DmPlugin { name = 'customSync'; dependencies = { onChange: 'core/ldap/onChange', }; hooks: Hooks = { onLdapMailChange: async (dn, oldMail, newMail) => { this.logger.info(`Mail changed: ${oldMail} → ${newMail}`); try { await fetch('https://api.example.com/update-email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dn, oldMail, newMail }), }); } catch (err) { this.logger.error(`Failed to sync mail change: ${err}`); } }, }; } ``` ## Change Detection ### Replace Operations ```javascript // Before: mail = "old@example.com" { replace: { mail: 'new@example.com'; } } // Triggers: onLdapMailChange("uid=user,ou=users,dc=example,dc=com", "old@example.com", "new@example.com") ``` ### Add Operations ```javascript // Before: mail = undefined { add: { mail: 'new@example.com'; } } // Triggers: onLdapMailChange("uid=user,ou=users,dc=example,dc=com", null, "new@example.com") ``` ### Delete Operations ```javascript // Before: mail = "old@example.com" { delete: ["mail"] } // Triggers: onLdapMailChange("uid=user,ou=users,dc=example,dc=com", "old@example.com", null) ``` ## Performance Considerations ### Hook Execution - Hooks run **synchronously** within the modify operation - Slow hooks delay the LDAP response - Consider async operations with fire-and-forget for non-critical updates ### Optimization Tips 1. **Keep hooks fast**: Defer heavy work to background jobs 2. **Use error handling**: Don't let hook failures block LDAP operations 3. **Batch updates**: If possible, batch multiple hook calls 4. **Cache external state**: Avoid redundant external API calls ## Error Handling Hooks can throw errors to abort the LDAP modification: ```javascript hooks: { onLdapMailChange: async (dn, oldMail, newMail) => { // Validate mail domain if (!newMail.endsWith('@company.com')) { throw new Error('Mail must be @company.com domain'); } // Update mail server try { await mailServer.renameAccount(oldMail, newMail); } catch (err) { throw new Error(`Mail server update failed: ${err.message}`); } }; } ``` If the hook throws, the LDAP modification is rolled back (if supported by LDAP server). ## Debugging Enable debug logging to see change detection: ```bash --log-level debug ``` Output: ``` [debug] Detected mail change for uid=jdoe,ou=users,dc=example,dc=com: old@domain.com → new@domain.com [debug] Triggering onLdapMailChange hook [info] onLdapMailChange: Mail account renamed successfully ``` ## Limitations 1. **Only monitors modify operations**: Does not detect changes from direct LDAP clients 2. **Requires old value lookup**: Fetches entry before modification for comparison 3. **Single-valued attributes**: Best suited for single-valued attributes like mail 4. **Synchronous execution**: Hooks block the LDAP response ## See Also - [Twake James Plugin](twakeJames.md) - Mail server integration using onChange - [ldapFlatGeneric.md](ldapFlatGeneric.md) - Generic LDAP entity management - [Hooks Documentation](../src/hooks.ts) - All available hooks linagora-ldap-rest-16e557e/docs/usage/plugins/ldap/organizations.md000066400000000000000000000437311522642357000254260ustar00rootroot00000000000000# 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"]` | 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 - Example: `ou=IT,ou=Departments,dc=example,dc=com` - Path: `IT / Departments / Top Organization` ### 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,dc=example,dc=com", "twakeDepartmentPath": "IT / Departments / Top Organization" } ``` ## 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": "IT / Government" } ``` ### 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" } ] ``` ### Create Organization ```http POST /api/v1/ldap/organizations ``` **Request Body:** ```json { "ou": "IT", "parentDn": "dc=example,dc=com", "description": "Information Technology Department", "twakeDepartmentPath": "IT / Government" } ``` **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 - 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: - Start with the organization's own `ou` name - Be followed by the path separator - Reference an existing parent organization in the hierarchy - Match the actual LDAP DN structure 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 if (isOrganization(entry)) { await checkDeptPath(entry); // 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,dc=example,dc=com", "description": "Recruitment Team", "twakeDepartmentPath": "Recruitment / HR / Government" }' ``` This creates: `ou=Recruitment,ou=HR,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": "IT / Government" }' ``` 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": "IT / Government", "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: ``` {current_ou} / {parent_ou} / {grandparent_ou} / ... / {top_org} ``` ### Validation Process 1. **Extract Components**: Split path by separator 2. **Verify First Component**: Must match organization's own `ou` 3. **Validate Parent Path**: Search for parent organization with matching path 4. **Verify Hierarchy**: Ensure path matches actual LDAP DN structure ### Example Path Validation For organization: `ou=Recruitment,ou=HR,dc=example,dc=com` Valid path: `Recruitment / HR / Government` - `Recruitment` matches current ou - `HR / Government` must exist as path of `ou=HR,dc=example,dc=com` Invalid path: `Recruitment / IT / Government` - `IT / Government` doesn't match parent's actual path ## 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", "twakeDepartmentPath": "IT / Government"}' # 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 path starts with 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 } } } ``` ## 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-16e557e/docs/usage/plugins/ldap/password-policy.md000066400000000000000000000302561522642357000256740ustar00rootroot00000000000000# Password Policy Plugin The `passwordPolicy` plugin provides a REST API for managing OpenLDAP password policies (ppolicy overlay). It allows administrators to 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 - 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-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 | ### 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. **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 } ``` **Response (404):** ```json { "error": "User not found: john.doe" } ``` ### 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" } ``` ## 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:** The ppolicy overlay may not be configured in OpenLDAP, or the ppolicy entry doesn't exist. Configure the overlay and create a pwdPolicy entry: ```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 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", "validatePassword": "/api/v1/password/validate" }, "config": { "warnDays": 14, "validateComplexity": true, "complexityRules": { "minLength": 12, "requireUppercase": true, "requireLowercase": true, "requireDigit": true, "requireSpecial": true } }, "ldapPolicy": { "dn": "cn=default,ou=policies,dc=example,dc=com", "pwdMaxAge": 7776000, "pwdMinLength": 12, "pwdLockout": true } } } } ``` **Note:** The `ldapPolicy` field contains the LDAP ppolicy configuration and is populated after the first request to any passwordPolicy endpoint. ## 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-16e557e/docs/usage/plugins/ldap/trash.md000066400000000000000000000165241522642357000236600ustar00rootroot00000000000000# 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`: Comma-separated list of branches to watch (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. ### 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 <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":"Missing required field: cn"} [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(.status >= 400)' ``` Filters 4xx and 5xx responses: ```json { "level": "info", "message": "GET /api/v1/ldap/users/notfound 404 12ms", "status": 404, "method": "GET", "url": "/api/v1/ldap/users/notfound" } ``` ### Statistics Generate request statistics: ```bash cat app.log | jq -r '.message' | awk '{print $1}' | 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(.responseTime) | "\(.url) \(.responseTime)"' | \ sed 's/ms$//' | \ 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 Configure log rotation using environment or external tools: ### Using Winston File Transport ```bash --log-file ./logs/ldap-rest.log \ --log-max-size 10485760 \ # 10MB --log-max-files 5 ``` ### 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-16e557e/docs/usage/troubleshooting.md000066400000000000000000000036521522642357000233630ustar00rootroot00000000000000# 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 Run the test suite: ```bash # All tests source ~/.test-env && npm run test:dev # Single file source ~/.test-env && npm run test:one test/path/to/file.test.ts ``` ## Support - **Issues:** https://github.com/linagora/ldap-rest/issues - **Documentation:** https://github.com/linagora/ldap-rest/tree/master/docs linagora-ldap-rest-16e557e/eslint.config.mjs000066400000000000000000000071601522642357000210310ustar00rootroot00000000000000import js from '@eslint/js'; import tseslint from '@typescript-eslint/eslint-plugin'; import tsparser from '@typescript-eslint/parser'; import prettier from 'eslint-plugin-prettier'; import prettierConfig from 'eslint-config-prettier'; import importPlugin from 'eslint-plugin-import'; import nodePlugin from 'eslint-plugin-node'; export default [ // Node.js config js.configs.recommended, // TypeScript config (except browser) { files: ['src/**/*.{ts,tsx}'], ignores: ['src/browser/**'], languageOptions: { parser: tsparser, parserOptions: { ecmaVersion: 2020, sourceType: 'module', project: './tsconfig.json', }, globals: { console: 'readonly', process: 'readonly', Buffer: 'readonly', __dirname: 'readonly', __filename: 'readonly', }, }, plugins: { '@typescript-eslint': tseslint, prettier: prettier, import: importPlugin, node: nodePlugin, }, rules: { // TS rules ...tseslint.configs.recommended.rules, ...tseslint.configs['recommended-requiring-type-checking'].rules, // Prettier ...prettierConfig.rules, 'prettier/prettier': 'error', // Custom '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_' }, ], '@typescript-eslint/explicit-function-return-type': 'warn', '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-var-requires': 'error', // Node.js rules 'node/no-missing-import': 'off', 'node/no-unsupported-features/es-syntax': 'off', 'node/no-unpublished-import': 'off', // Import rules 'import/order': [ 'error', { groups: [ 'builtin', 'external', 'internal', 'parent', 'sibling', 'index', ], 'newlines-between': 'always', }, ], // General rules // 'no-console': 'warn', 'prefer-const': 'error', 'no-var': 'error', }, }, // Browser-specific config { files: ['src/browser/**/*.{ts,tsx}'], languageOptions: { parser: tsparser, parserOptions: { ecmaVersion: 2020, sourceType: 'module', project: './tsconfig.json', }, globals: { console: 'readonly', document: 'readonly', window: 'readonly', fetch: 'readonly', HTMLElement: 'readonly', CustomEvent: 'readonly', alert: 'readonly', confirm: 'readonly', setTimeout: 'readonly', clearTimeout: 'readonly', }, }, plugins: { '@typescript-eslint': tseslint, prettier: prettier, }, rules: { ...tseslint.configs.recommended.rules, ...prettierConfig.rules, 'prettier/prettier': 'error', '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_' }, ], '@typescript-eslint/explicit-function-return-type': 'warn', '@typescript-eslint/no-explicit-any': 'warn', 'prefer-const': 'error', 'no-var': 'error', }, }, // JavaScript-specific config { files: ['*.js', '*.mjs'], languageOptions: { ecmaVersion: 2020, sourceType: 'module', }, rules: { '@typescript-eslint/no-var-requires': 'off', }, }, // Ignored files { ignores: [ 'dist/**', 'build/**', 'node_modules/**', '*.min.js', '*.map', '*.d.ts', 'coverage/**', '.nyc_output/**', 'logs/**', '*.log', 'eslint.config.js', ], }, ]; linagora-ldap-rest-16e557e/examples/000077500000000000000000000000001522642357000173665ustar00rootroot00000000000000linagora-ldap-rest-16e557e/examples/web/000077500000000000000000000000001522642357000201435ustar00rootroot00000000000000linagora-ldap-rest-16e557e/examples/web/README.md000066400000000000000000000074121522642357000214260ustar00rootroot00000000000000# Browser Examples This directory contains interactive HTML examples demonstrating the use of LDAP-Rest browser libraries. ## Available Examples ### 🔐 TOTP Client (`totp-client.html`) Interactive demonstration of the TOTP authentication client library. **Features:** - Real-time TOTP code generation - Countdown timer with visual indicator - Copy to clipboard functionality - API client testing - Configuration options (digits, time step) - Code examples for npm usage **Usage:** ```bash # Start the LDAP-Rest server npm start # Open in browser http://localhost:8081/static/examples/web/totp-client.html ``` **NPM Module:** ```typescript import { generateTotp, TotpAuthClient, getRemainingSeconds, isValidBase32, } from 'ldap-rest/browser-shared-utils-totp'; ``` ### 📁 LDAP Tree Viewer (`ldap-tree-viewer.html`) Visualize and navigate LDAP directory structure. **Features:** - Interactive tree navigation - Node details display - Expand/collapse controls - Refresh functionality ### 👥 LDAP User Editor (`ldap-user-editor.html`) Browse and edit LDAP user entries. **Features:** - User list and tree view - Edit user properties - Move users between branches - Add/delete users ### 👔 LDAP Group Editor (`ldap-group-editor.html`) Manage LDAP groups and memberships. **Features:** - Group tree navigation - Edit group properties - Manage members - Create/move/delete groups ### 🏢 LDAP Unit Editor (`ldap-unit-editor.html`) Manage organizational units. **Features:** - Unit tree structure - Edit unit properties - Move units - Create/delete units ## Running Examples 1. **Start the server:** ```bash npm start # or npm run dev ``` 2. **Configure authentication:** For TOTP example: ```bash npm start -- \ --plugin core/auth/totp \ --auth-totp "JBSWY3DPEHPK3PXP:admin:6" ``` 3. **Open examples in browser:** ``` http://localhost:8081/static/examples/web/totp-client.html http://localhost:8081/static/examples/web/ldap-tree-viewer.html http://localhost:8081/static/examples/web/ldap-user-editor.html http://localhost:8081/static/examples/web/ldap-group-editor.html http://localhost:8081/static/examples/web/ldap-unit-editor.html ``` ## Using Browser Libraries in Your Application All browser libraries are available as npm module exports: ```typescript // TOTP Client import { TotpAuthClient } from 'ldap-rest/browser-shared-utils-totp'; // Shared utilities import { escapeHtml, createElement } from 'ldap-rest/browser-shared-utils-dom'; import { Modal } from 'ldap-rest/browser-shared-components-modal'; import { showStatus } from 'ldap-rest/browser-shared-components-statusmessage'; // LDAP Tree Viewer import { LdapTreeViewer } from 'ldap-rest/browser-ldap-tree-viewer-ldaptreeviewer'; // User Editor import { LdapUserEditor } from 'ldap-rest/browser-ldap-user-editor-ldapusereditor'; // Group Editor import { LdapGroupEditor } from 'ldap-rest/browser-ldap-group-editor-ldapgroupeditor'; ``` See [package.json exports](../../package.json) for complete list of available modules. ## Development ### Building Examples Examples are automatically built when you run: ```bash npm run build # or npm run build:browser ``` ### Modifying Examples 1. Edit the source in `src/browser/` 2. Rebuild: `npm run build:browser` 3. Refresh browser to see changes ### Live Development Use watch mode for automatic rebuilds: ```bash npm run build:watch ``` ## Documentation - [Authentication Guide](../../docs/authentication.md) - TOTP, Token, SSO authentication - [Developer Guide](../../docs/DEVELOPER_GUIDE.md) - Building applications with LDAP-Rest - [Browser API Documentation](../../docs/) - Detailed API documentation ## License Copyright 2025-present [LINAGORA](https://linagora.com) Licensed under [GNU AGPL-3.0](../../LICENSE) linagora-ldap-rest-16e557e/examples/web/ldap-group-editor.html000066400000000000000000000056161522642357000243770ustar00rootroot00000000000000 LDAP Group Editor Demo

👥 LDAP Group Editor

Browse and manage LDAP groups with support for mailing lists and team mailboxes

linagora-ldap-rest-16e557e/examples/web/ldap-tree-viewer.html000066400000000000000000000311171522642357000242100ustar00rootroot00000000000000 LDAP Tree Viewer Demo

📁 LDAP Tree Viewer

Interactive visualization of LDAP directory structure

account_tree Directory Tree

info Node Details

touch_app

Click on a node to see details

linagora-ldap-rest-16e557e/examples/web/ldap-unit-editor.html000066400000000000000000000055071522642357000242210ustar00rootroot00000000000000 LDAP Unit Editor Demo

🏢 LDAP Organization Unit Editor

Manage organizational units and departments in your LDAP directory

linagora-ldap-rest-16e557e/examples/web/ldap-user-editor.html000066400000000000000000000317671522642357000242270ustar00rootroot00000000000000 LDAP User Editor Demo

✏️ LDAP User Editor

Browse and edit LDAP users with schema-driven forms

linagora-ldap-rest-16e557e/examples/web/totp-client.html000066400000000000000000000371221522642357000233000ustar00rootroot00000000000000 TOTP Client Demo

🔐 TOTP Client Demo

Time-based One-Time Password Generator and API Client

vpn_key TOTP Generator

What is TOTP?

TOTP generates temporary codes that change every 30 seconds. Enter your Base32 secret to generate codes compatible with Google Authenticator, Authy, and other authenticator apps.

------
Waiting for code generation...

http API Client Test

API Authentication

The TOTP client automatically adds the current code to the Authorization: Bearer header for each request.

Test API Request

Test the TOTP authentication by making a request to the LDAP REST API.

code Usage Examples

Installation

npm install ldap-rest # or yarn add ldap-rest

Import the TOTP Client

import { generateTotp, TotpAuthClient } from 'ldap-rest/browser-shared-utils-totp';

Generate TOTP Code

// Generate a TOTP code const code = await generateTotp({ secret: 'JBSWY3DPEHPK3PXP', digits: 6, step: 30 }); console.log('Current TOTP code:', code);

Use TotpAuthClient for API Requests

// Create authenticated client const client = new TotpAuthClient({ secret: 'JBSWY3DPEHPK3PXP', digits: 6, step: 30 }); // Make authenticated requests const users = await client.get('/api/v1/ldap/users'); const userData = await users.json(); // POST with automatic authentication 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');

Helper Functions

import { getRemainingSeconds, isValidBase32 } from 'ldap-rest/browser-shared-utils-totp'; // Check how much time until next code const remaining = getRemainingSeconds(30); console.log(`Code expires in ${remaining} seconds`); // Validate Base32 secret if (isValidBase32('JBSWY3DPEHPK3PXP')) { console.log('Valid secret!'); }
linagora-ldap-rest-16e557e/helm/000077500000000000000000000000001522642357000164755ustar00rootroot00000000000000linagora-ldap-rest-16e557e/helm/ldap-rest/000077500000000000000000000000001522642357000203705ustar00rootroot00000000000000linagora-ldap-rest-16e557e/helm/ldap-rest/.helmignore000066400000000000000000000002541522642357000225230ustar00rootroot00000000000000# Patterns to ignore when building packages. .DS_Store .git/ .gitignore .bzr/ .bzrignore .hg/ .hgignore .svn/ *.swp *.bak *.tmp *.orig *~ .project .idea/ *.tmproj .vscode/ linagora-ldap-rest-16e557e/helm/ldap-rest/Chart.yaml000066400000000000000000000012001522642357000223060ustar00rootroot00000000000000apiVersion: v2 name: ldap-rest description: A Helm chart for deploying ldap-rest — a REST/SCIM API server in front of an LDAP directory type: application # version and appVersion are overwritten from the git tag by the release # workflow (.github/workflows/release.yml). The values here are only a # fallback for local `helm package` / `helm install` from a checkout. version: 0.4.5 appVersion: "0.4.5" home: https://github.com/linagora/ldap-rest keywords: - ldap - rest - scim - directory - twake maintainers: - name: yadd url: https://github.com/linagora/ldap-rest sources: - https://github.com/linagora/ldap-rest linagora-ldap-rest-16e557e/helm/ldap-rest/README.md000066400000000000000000000044121522642357000216500ustar00rootroot00000000000000# ldap-rest Helm chart Deploys [ldap-rest](https://github.com/linagora/ldap-rest), a REST/SCIM API server that sits in front of an LDAP directory. The chart is published as an OCI artifact on each release tag. ## Install ```bash # Latest release helm install ldap-rest oci://ghcr.io/linagora/charts/ldap-rest # A specific version helm install ldap-rest oci://ghcr.io/linagora/charts/ldap-rest --version 0.4.5 # With your own values helm install ldap-rest oci://ghcr.io/linagora/charts/ldap-rest -f my-values.yaml ``` ## Configuration ldap-rest is configured entirely through `DM_*` environment variables. Pass non-secret ones via `env` and sensitive ones via `secrets` (both are plain Kubernetes env lists): ```yaml image: # ghcr.io/linagora/ldap-rest (default) or docker.io/yadd/ldap-rest repository: ghcr.io/linagora/ldap-rest tag: "" # defaults to the chart appVersion service: port: 8081 # keep in sync with DM_PORT if you override it env: - name: DM_LDAP_URL value: "ldap://openldap" - name: DM_LDAP_BASE value: "dc=example,dc=com" - name: DM_PLUGINS value: "core/static,core/ldap/flatGeneric" secrets: - name: DM_LDAP_PWD valueFrom: secretKeyRef: name: ldap-rest-secrets key: DM_LDAP_PWD ``` ### Key values | Key | Default | Description | |-----|---------|-------------| | `replicaCount` | `1` | Number of pods (stateless, scalable). | | `image.repository` | `ghcr.io/linagora/ldap-rest` | Image repository. | | `image.tag` | `""` | Image tag; defaults to chart `appVersion`. | | `service.type` / `service.port` | `ClusterIP` / `8081` | Service exposure. `port` is also the container port. | | `ingress.enabled` | `false` | Enable an Ingress. | | `env` | `[]` | Plain `DM_*` env vars (list of `{name, value}`). | | `secrets` | `[]` | Sensitive env vars (list, prefer `valueFrom.secretKeyRef`). | | `externalFileConfig` | `""` | Inline JS plugin mounted at `/app/external/cnb-plugin.js`. | | `resources` | see `values.yaml` | CPU/memory requests and limits. | | `livenessProbe` / `readinessProbe` | TCP on `http` | Probes (no HTTP health route exists; `{}` disables). | See [`values.yaml`](./values.yaml) for the full list. > No dedicated HTTP health endpoint exists, so the probes use a TCP check on the > listen port. linagora-ldap-rest-16e557e/helm/ldap-rest/templates/000077500000000000000000000000001522642357000223665ustar00rootroot00000000000000linagora-ldap-rest-16e557e/helm/ldap-rest/templates/NOTES.txt000066400000000000000000000022051522642357000240160ustar00rootroot00000000000000ldap-rest has been deployed as release "{{ .Release.Name }}". The API is reachable inside the cluster at: http://{{ include "ldap-rest.fullname" . }}.{{ .Release.Namespace }}.svc:{{ .Values.service.port }} {{- if .Values.ingress.enabled }} Ingress hosts: {{- range .Values.ingress.hosts }} - http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }} {{- end }} {{- else }} To reach it locally: kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "ldap-rest.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }} # then, e.g. (adjust the prefix to your DM_API_PREFIX, default /api): curl http://localhost:{{ .Values.service.port }}/api/hello {{- end }} Useful commands: # Logs kubectl -n {{ .Release.Namespace }} logs -l app.kubernetes.io/name={{ include "ldap-rest.name" . }} -f {{- if not .Values.env }} ⚠️ No `env` values set — ldap-rest is running with image defaults (DM_LDAP_URL=ldap://localhost, empty DM_LDAP_BASE). Configure at least DM_LDAP_URL / DM_LDAP_BASE (and DM_LDAP_PWD via `secrets`) for real use. {{- end }} For configuration options, see: https://github.com/linagora/ldap-rest linagora-ldap-rest-16e557e/helm/ldap-rest/templates/_helpers.tpl000066400000000000000000000036131522642357000247130ustar00rootroot00000000000000{{/* Expand the name of the chart. */}} {{- define "ldap-rest.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Create a default fully qualified app name. If release name contains chart name it will be used as a full name. */}} {{- define "ldap-rest.fullname" -}} {{- if .Values.fullnameOverride }} {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} {{- else }} {{- $name := default .Chart.Name .Values.nameOverride }} {{- if contains $name .Release.Name }} {{- .Release.Name | trunc 63 | trimSuffix "-" }} {{- else }} {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} {{- end }} {{- end }} {{- end }} {{/* Create chart name and version as used by the chart label. */}} {{- define "ldap-rest.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Common labels */}} {{- define "ldap-rest.labels" -}} helm.sh/chart: {{ include "ldap-rest.chart" . }} {{ include "ldap-rest.selectorLabels" . }} {{- if .Chart.AppVersion }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} {{- end }} app.kubernetes.io/managed-by: {{ .Release.Service }} {{- end }} {{/* Selector labels */}} {{- define "ldap-rest.selectorLabels" -}} app.kubernetes.io/name: {{ include "ldap-rest.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} {{/* Create the name of the service account to use */}} {{- define "ldap-rest.serviceAccountName" -}} {{- if .Values.serviceAccount.create }} {{- default (include "ldap-rest.fullname" .) .Values.serviceAccount.name }} {{- else }} {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }} {{/* Container image reference (tag defaults to the chart appVersion) */}} {{- define "ldap-rest.image" -}} {{- $tag := .Values.image.tag | default .Chart.AppVersion -}} {{- printf "%s:%s" .Values.image.repository $tag -}} {{- end }} linagora-ldap-rest-16e557e/helm/ldap-rest/templates/configmap.yaml000066400000000000000000000004231522642357000252140ustar00rootroot00000000000000{{- if .Values.externalFileConfig }} apiVersion: v1 kind: ConfigMap metadata: name: {{ include "ldap-rest.fullname" . }}-external labels: {{- include "ldap-rest.labels" . | nindent 4 }} data: cnb-plugin.js: | {{ .Values.externalFileConfig | nindent 4 }} {{- end }} linagora-ldap-rest-16e557e/helm/ldap-rest/templates/deployment.yaml000066400000000000000000000062311522642357000254340ustar00rootroot00000000000000apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "ldap-rest.fullname" . }} labels: {{- include "ldap-rest.labels" . | nindent 4 }} spec: replicas: {{ .Values.replicaCount }} {{- with .Values.strategy }} strategy: {{- toYaml . | nindent 4 }} {{- end }} selector: matchLabels: {{- include "ldap-rest.selectorLabels" . | nindent 6 }} template: metadata: labels: {{- include "ldap-rest.selectorLabels" . | nindent 8 }} annotations: {{- if .Values.externalFileConfig }} checksum/config: {{ .Values.externalFileConfig | sha256sum }} {{- end }} {{- with .Values.podAnnotations }} {{- toYaml . | nindent 8 }} {{- end }} spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} serviceAccountName: {{ include "ldap-rest.serviceAccountName" . }} {{- with .Values.podSecurityContext }} securityContext: {{- toYaml . | nindent 8 }} {{- end }} containers: - name: ldap-rest image: {{ include "ldap-rest.image" . | quote }} imagePullPolicy: {{ .Values.image.pullPolicy }} {{- with .Values.securityContext }} securityContext: {{- toYaml . | nindent 12 }} {{- end }} ports: - name: http containerPort: {{ .Values.service.port }} protocol: TCP {{- if or .Values.env .Values.secrets }} env: {{- with .Values.env }} {{- toYaml . | nindent 12 }} {{- end }} {{- with .Values.secrets }} {{- toYaml . | nindent 12 }} {{- end }} {{- end }} {{- with .Values.livenessProbe }} livenessProbe: {{- toYaml . | nindent 12 }} {{- end }} {{- with .Values.readinessProbe }} readinessProbe: {{- toYaml . | nindent 12 }} {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} {{- if or .Values.externalFileConfig .Values.extraVolumeMounts }} volumeMounts: {{- if .Values.externalFileConfig }} - name: external-plugin mountPath: /app/external readOnly: true {{- end }} {{- with .Values.extraVolumeMounts }} {{- toYaml . | nindent 12 }} {{- end }} {{- end }} {{- if or .Values.externalFileConfig .Values.extraVolumes }} volumes: {{- if .Values.externalFileConfig }} - name: external-plugin configMap: name: {{ include "ldap-rest.fullname" . }}-external {{- end }} {{- with .Values.extraVolumes }} {{- toYaml . | nindent 8 }} {{- end }} {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} {{- with .Values.tolerations }} tolerations: {{- toYaml . | nindent 8 }} {{- end }} {{- with .Values.affinity }} affinity: {{- toYaml . | nindent 8 }} {{- end }} linagora-ldap-rest-16e557e/helm/ldap-rest/templates/ingress.yaml000066400000000000000000000020211522642357000247170ustar00rootroot00000000000000{{- if .Values.ingress.enabled }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include "ldap-rest.fullname" . }} labels: {{- include "ldap-rest.labels" . | nindent 4 }} {{- with .Values.ingress.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: {{- if .Values.ingress.className }} ingressClassName: {{ .Values.ingress.className }} {{- end }} {{- if .Values.ingress.tls }} tls: {{- range .Values.ingress.tls }} - hosts: {{- range .hosts }} - {{ . | quote }} {{- end }} secretName: {{ .secretName }} {{- end }} {{- end }} rules: {{- range .Values.ingress.hosts }} - host: {{ .host | quote }} http: paths: {{- range .paths }} - path: {{ .path }} pathType: {{ .pathType }} backend: service: name: {{ include "ldap-rest.fullname" $ }} port: name: http {{- end }} {{- end }} {{- end }} linagora-ldap-rest-16e557e/helm/ldap-rest/templates/service.yaml000066400000000000000000000007251522642357000247160ustar00rootroot00000000000000apiVersion: v1 kind: Service metadata: name: {{ include "ldap-rest.fullname" . }} labels: {{- include "ldap-rest.labels" . | nindent 4 }} {{- with .Values.service.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: type: {{ .Values.service.type }} ports: - name: http port: {{ .Values.service.port }} targetPort: http protocol: TCP selector: {{- include "ldap-rest.selectorLabels" . | nindent 4 }} linagora-ldap-rest-16e557e/helm/ldap-rest/templates/serviceaccount.yaml000066400000000000000000000005031522642357000262650ustar00rootroot00000000000000{{- if .Values.serviceAccount.create }} apiVersion: v1 kind: ServiceAccount metadata: name: {{ include "ldap-rest.serviceAccountName" . }} labels: {{- include "ldap-rest.labels" . | nindent 4 }} {{- with .Values.serviceAccount.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} {{- end }} linagora-ldap-rest-16e557e/helm/ldap-rest/values.yaml000066400000000000000000000101201522642357000225450ustar00rootroot00000000000000# Default values for the ldap-rest Helm chart. # # ldap-rest is configured entirely through DM_* environment variables (see the # project Dockerfile / documentation). Pass them through `env` (plain values) # and `secrets` (sensitive values). This keeps the same values interface as the # in-house deployment chart, so existing overlays work unchanged. # -- Override the chart name used in labels nameOverride: "" # -- Fully override the generated resource name (defaults to the release name) fullnameOverride: "" # -- Number of replicas. ldap-rest is stateless (all state lives in LDAP), so it # can be scaled horizontally. replicaCount: 1 image: # -- Image repository. Published to both ghcr.io/linagora/ldap-rest and # docker.io/yadd/ldap-rest by the release workflow. repository: ghcr.io/linagora/ldap-rest # -- Image tag. Defaults to the chart appVersion when left empty. tag: "" # -- Image pull policy pullPolicy: IfNotPresent # -- Image pull secrets for private registries imagePullSecrets: [] # -- Deployment update strategy strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1 serviceAccount: # -- Create a dedicated ServiceAccount create: false # -- Annotations to add to the ServiceAccount annotations: {} # -- Name of the ServiceAccount to use (generated if empty and create=true) name: "" # -- Extra pod annotations. A checksum of externalFileConfig is always added on # top so pods roll automatically when the mounted plugin changes. podAnnotations: {} # -- Pod-level security context. The image runs as the unprivileged `node` user. podSecurityContext: {} # fsGroup: 1000 # -- Container-level security context securityContext: {} # runAsNonRoot: true # allowPrivilegeEscalation: false # readOnlyRootFilesystem: false # capabilities: # drop: # - ALL service: # -- Service type: ClusterIP, NodePort, LoadBalancer type: ClusterIP # -- Service port. Also used as the container port, so keep it in sync with # DM_PORT if you override the listen port through `env`. port: 8081 # -- Annotations annotations: {} ingress: # -- Enable ingress enabled: false # -- Ingress class name className: "" # -- Ingress annotations annotations: {} # -- Ingress hosts hosts: - host: ldap-rest.local paths: - path: / pathType: Prefix # -- TLS configuration tls: [] # - secretName: ldap-rest-tls # hosts: # - ldap-rest.local resources: requests: cpu: 100m memory: 512Mi limits: cpu: 250m memory: 1Gi # -- Liveness probe. No dedicated HTTP health route exists, so a TCP check on # the listen port is used. Set to {} to disable. livenessProbe: tcpSocket: port: http initialDelaySeconds: 10 periodSeconds: 15 timeoutSeconds: 3 failureThreshold: 3 # -- Readiness probe. Set to {} to disable. readinessProbe: tcpSocket: port: http initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3 # -- Node selector nodeSelector: {} # -- Tolerations tolerations: [] # -- Affinity rules affinity: {} # -- Plain (non-secret) environment variables, as a list of {name, value}. # Every ldap-rest setting is a DM_* variable. Example: # env: # - name: DM_LDAP_URL # value: "ldap://openldap" # - name: DM_LDAP_BASE # value: "dc=example,dc=com" # - name: DM_PLUGINS # value: "core/static,core/ldap/flatGeneric" env: [] # -- Sensitive environment variables, as a list of env entries. Prefer # `valueFrom.secretKeyRef` pointing at a Secret you manage (e.g. via SOPS / # External Secrets) rather than inlining values here. Example: # secrets: # - name: DM_LDAP_PWD # valueFrom: # secretKeyRef: # name: ldap-rest-secrets # key: DM_LDAP_PWD secrets: [] # -- Inline JavaScript for an external plugin. When set, it is rendered into a # ConfigMap and mounted at /app/external/cnb-plugin.js (reference it in # DM_PLUGINS as /app/external/cnb-plugin.js). externalFileConfig: "" # -- Extra volumes to add to the pod extraVolumes: [] # -- Extra volume mounts to add to the container extraVolumeMounts: [] linagora-ldap-rest-16e557e/lsc-plugin/000077500000000000000000000000001522642357000176255ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/.gitignore000066400000000000000000000000651522642357000216160ustar00rootroot00000000000000target/ *.iml .idea/ .vscode/ test/integration/logs/ linagora-ldap-rest-16e557e/lsc-plugin/README.md000066400000000000000000000065751522642357000211210ustar00rootroot00000000000000# LSC ldap-rest plugin A LSC `pluginDestinationService` that translates `LscModifications` into HTTP calls against the [ldap-rest](https://github.com/linagora/ldap-rest) REST API, instead of binding directly to LDAP. This way LSC sync benefits from ldap-rest's ACL, schema validation, audit, and downstream provisioning hooks (Twake James, Cozy, SCIM, RabbitMQ). ## Status Standalone plugin shipped alongside ldap-rest while a native `LdapRestDstService` is being upstreamed into `lsc-project/lsc`. ## Build ```sh mvn package ``` Produces `target/lsc-ldaprest-plugin--with-deps.jar` (jar + Jackson shaded). Drop it into `/usr/lib/lsc/` (or wherever LSC reads its classpath from in your deployment). ## Configuration In your LSC `lsc.xml`, declare the destination service: ```xml ldap-rest-users https://ldap-rest.example.org users ${LDAP_REST_TOKEN} 10000 3 ``` `` is one of: `users` (or any other flat-resource plural name declared in ldap-rest), `groups`, `organizations`. One destination service instance maps to one resource type — declare several services if you sync several resource families. ## Operation mapping | LSC operation | ldap-rest call | | ------------- | --------------------------------------------------------------------- | | CREATE flat | `POST /api/v1/ldap/{resource}` body `{ }` | | UPDATE flat | `PUT /api/v1/ldap/{resource}/{id}` body `{ add?, replace?, delete? }` | | DELETE flat | `DELETE /api/v1/ldap/{resource}/{id}` | | MODRDN flat | `POST /api/v1/ldap/{resource}/{id}/move` body `{ targetOrgDn }` | | CREATE group | `POST /api/v1/ldap/groups` | | UPDATE group | `PUT /api/v1/ldap/groups/{cn}` + member POST/DELETE | | DELETE group | `DELETE /api/v1/ldap/groups/{cn}` | | RENAME group | `POST /api/v1/ldap/groups/{cn}/rename` body `{ newCn }` | | CREATE org | `POST /api/v1/ldap/organizations` | | UPDATE org | `PUT /api/v1/ldap/organizations/{dn}` | | DELETE org | `DELETE /api/v1/ldap/organizations/{dn}` | | MODRDN org | `POST /api/v1/ldap/organizations/{dn}/move` | ## Limitations - Binary attributes (`jpegPhoto`, `userCertificate`) are not synced in this version: ldap-rest has no formal JSON convention for them yet. The plugin fails fast with a clear error if it encounters one. - No bulk endpoint: large initial syncs do N HTTP calls. If this hurts, ldap-rest will need a bulk endpoint first. ## Tests - `mvn test` runs the WireMock-based unit tests. - `test/integration/tests/run.sh` spins up a full Docker stack (OpenLDAP source + OpenLDAP target + ldap-rest + LSC with the plugin) and asserts end-to-end sync. See `test/integration/README.md`. linagora-ldap-rest-16e557e/lsc-plugin/pom.xml000066400000000000000000000073001522642357000211420ustar00rootroot00000000000000 4.0.0 org.lscproject lsc-ldaprest-plugin 0.1.0-SNAPSHOT jar LSC ldap-rest destination plugin LSC pluginDestinationService that writes through the ldap-rest HTTP API instead of binding directly to LDAP. Supports flat resources, groups, and organizations, with Bearer or HMAC-SHA256 authentication. 11 11 UTF-8 2.2 2.17.2 5.10.2 3.9.1 org.lsc lsc-core ${lsc.version} provided com.fasterxml.jackson.core jackson-databind ${jackson.version} org.slf4j slf4j-api 2.0.13 provided org.junit.jupiter junit-jupiter ${junit.version} test org.wiremock wiremock-standalone ${wiremock.version} test ${project.artifactId}-${project.version} maven-surefire-plugin 3.2.5 org.apache.maven.plugins maven-shade-plugin 3.5.3 package shade false true with-deps com.fasterxml.jackson org.lscproject.ldaprest.shaded.jackson linagora-ldap-rest-16e557e/lsc-plugin/src/000077500000000000000000000000001522642357000204145ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/src/main/000077500000000000000000000000001522642357000213405ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/src/main/java/000077500000000000000000000000001522642357000222615ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/src/main/java/org/000077500000000000000000000000001522642357000230505ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/src/main/java/org/lscproject/000077500000000000000000000000001522642357000252205ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/src/main/java/org/lscproject/ldaprest/000077500000000000000000000000001522642357000270365ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/src/main/java/org/lscproject/ldaprest/BearerAuth.java000066400000000000000000000016651522642357000317330ustar00rootroot00000000000000/* * SPDX-License-Identifier: BSD-3-Clause */ package org.lscproject.ldaprest; import java.net.http.HttpRequest; import java.util.Objects; /** * Static bearer-token authentication. * *

Adds {@code Authorization: Bearer <token>} to every * outgoing request. The token is supposed to be opaque to the client * and is provided by the ldap-rest deployment (typically through an * environment variable expanded in {@code lsc.xml}).

*/ public final class BearerAuth implements LdapRestAuth { private final String token; public BearerAuth(String token) { this.token = Objects.requireNonNull(token, "token"); if (token.isEmpty()) { throw new IllegalArgumentException("bearer token must not be empty"); } } @Override public void apply(HttpRequest.Builder builder, String method, String path, byte[] body) { builder.header("Authorization", "Bearer " + token); } } linagora-ldap-rest-16e557e/lsc-plugin/src/main/java/org/lscproject/ldaprest/HmacAuth.java000066400000000000000000000112741522642357000314000ustar00rootroot00000000000000/* * SPDX-License-Identifier: BSD-3-Clause */ package org.lscproject.ldaprest; import java.net.http.HttpRequest; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Locale; import java.util.Objects; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; /** * HMAC-SHA256 request signing matching the ldap-rest server-side * verification. The signing string is: * *
 *   signingString = METHOD + "|" + PATH + "|" + timestamp + "|" + bodyHash
 *   bodyHash      = sha256_hex(body)            (POST/PUT/PATCH)
 *                 = ""                          (GET/DELETE/HEAD)
 *   timestamp     = currentTimeMillis()         (milliseconds)
 *   signature     = hmac_sha256_hex(secret, signingString)
 *   header        = "HMAC-SHA256 ::"
 * 
* *

The body bytes used for {@code bodyHash} must * be the exact bytes sent on the wire (UTF-8 encoded JSON in our * case). The HTTP client takes care of feeding the same byte array * to both the signature and the request body.

*/ public final class HmacAuth implements LdapRestAuth { private static final char[] HEX = "0123456789abcdef".toCharArray(); private final String serviceId; private final String secret; private final TimestampSource clock; public HmacAuth(String serviceId, String secret) { this(serviceId, secret, System::currentTimeMillis); } /** * Test-only constructor injecting a fixed timestamp source so * signatures are reproducible. */ public HmacAuth(String serviceId, String secret, TimestampSource clock) { this.serviceId = Objects.requireNonNull(serviceId, "serviceId"); this.secret = Objects.requireNonNull(secret, "secret"); this.clock = Objects.requireNonNull(clock, "clock"); if (serviceId.isEmpty() || secret.isEmpty()) { throw new IllegalArgumentException("serviceId and secret must not be empty"); } } @Override public void apply(HttpRequest.Builder builder, String method, String path, byte[] body) { long ts = clock.now(); String upper = method.toUpperCase(Locale.ROOT); boolean hasBody = upper.equals("POST") || upper.equals("PUT") || upper.equals("PATCH"); String bodyHash = hasBody ? sha256Hex(body == null ? new byte[0] : body) : ""; String signingString = upper + "|" + path + "|" + ts + "|" + bodyHash; String signature = hmacSha256Hex(secret, signingString); String headerValue = "HMAC-SHA256 " + serviceId + ":" + ts + ":" + signature; builder.header("Authorization", headerValue); } /** * Compute the signature for given inputs without sending a * request. Exposed so callers (and tests) can pre-compute * expected values. */ public static String computeSignature(String secret, String method, String path, long timestamp, byte[] body) { String upper = method.toUpperCase(Locale.ROOT); boolean hasBody = upper.equals("POST") || upper.equals("PUT") || upper.equals("PATCH"); String bodyHash = hasBody ? sha256Hex(body == null ? new byte[0] : body) : ""; String signingString = upper + "|" + path + "|" + timestamp + "|" + bodyHash; return hmacSha256Hex(secret, signingString); } static String sha256Hex(byte[] data) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); return toHex(md.digest(data)); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-256 not available", e); } } static String hmacSha256Hex(String secret, String signingString) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] sig = mac.doFinal(signingString.getBytes(StandardCharsets.UTF_8)); return toHex(sig); } catch (Exception e) { throw new IllegalStateException("HmacSHA256 unavailable", e); } } static String toHex(byte[] data) { char[] out = new char[data.length * 2]; for (int i = 0; i < data.length; i++) { int v = data[i] & 0xff; out[i * 2] = HEX[v >>> 4]; out[i * 2 + 1] = HEX[v & 0x0f]; } return new String(out); } /** Test seam for clock injection. */ @FunctionalInterface public interface TimestampSource { long now(); } } linagora-ldap-rest-16e557e/lsc-plugin/src/main/java/org/lscproject/ldaprest/LdapRestAuth.java000066400000000000000000000023741522642357000322470ustar00rootroot00000000000000/* * SPDX-License-Identifier: BSD-3-Clause * * LSC ldap-rest plugin - authentication strategies. */ package org.lscproject.ldaprest; import java.net.http.HttpRequest; /** * Pluggable authentication strategy that adds the relevant * Authorization header on an outgoing HTTP request to ldap-rest. * *

The strategy receives the parsed components of the request * ({@code method}, {@code path}, {@code body}) so that signed schemes * (HMAC) can compute their signature. Stateless schemes (Bearer) * simply add a static header.

* *

Implementations must be safe for concurrent use * across multiple HTTP clients/threads.

*/ public interface LdapRestAuth { /** * Apply this auth strategy to {@code builder}, signing the * outgoing request based on its method, path and body. * * @param builder the {@link HttpRequest.Builder} being built * @param method the HTTP method, upper case (e.g. {@code POST}) * @param path the request path, e.g. {@code /api/v1/ldap/users} * @param body the body bytes that will be sent, or empty for * methods without a body (GET, DELETE, HEAD) */ void apply(HttpRequest.Builder builder, String method, String path, byte[] body); } linagora-ldap-rest-16e557e/lsc-plugin/src/main/java/org/lscproject/ldaprest/LdapRestClient.java000066400000000000000000000176201522642357000325640ustar00rootroot00000000000000/* * SPDX-License-Identifier: BSD-3-Clause */ package org.lscproject.ldaprest; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Objects; import org.lsc.exception.LscServiceException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Thin wrapper around {@link HttpClient} for talking to ldap-rest. * *

Responsibilities:

*
    *
  • Apply the configured {@link LdapRestAuth} on every request, * feeding it the same body bytes that go on the wire so that * HMAC signatures match.
  • *
  • Build the absolute URL from a base URL and path.
  • *
  • Retry transient errors (5xx, IOException) with capped * exponential backoff.
  • *
  • Translate non-2xx responses into {@link LscServiceException}.
  • *
* *

Idempotent special cases (404 on DELETE) are handled by the * caller, not here, so the client stays generic.

*/ public class LdapRestClient { private static final Logger LOG = LoggerFactory.getLogger(LdapRestClient.class); private final String baseUrl; private final LdapRestAuth auth; private final long timeoutMs; private final int retries; private final HttpClient http; private final Sleeper sleeper; public LdapRestClient(String baseUrl, LdapRestAuth auth, long timeoutMs, int retries) { this(baseUrl, auth, timeoutMs, retries, HttpClient.newBuilder() .connectTimeout(Duration.ofMillis(Math.max(1000, timeoutMs))) .build(), ms -> { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } /** Test-friendly constructor allowing HttpClient and Sleeper injection. */ public LdapRestClient(String baseUrl, LdapRestAuth auth, long timeoutMs, int retries, HttpClient http, Sleeper sleeper) { this.baseUrl = stripTrailingSlash(Objects.requireNonNull(baseUrl, "baseUrl")); this.auth = Objects.requireNonNull(auth, "auth"); if (timeoutMs <= 0) { throw new IllegalArgumentException("timeoutMs must be positive"); } if (retries < 0) { throw new IllegalArgumentException("retries must be >= 0"); } this.timeoutMs = timeoutMs; this.retries = retries; this.http = Objects.requireNonNull(http, "http"); this.sleeper = Objects.requireNonNull(sleeper, "sleeper"); } public HttpResponse get(String path) throws LscServiceException { return send("GET", path, null); } public HttpResponse delete(String path) throws LscServiceException { return send("DELETE", path, null); } public HttpResponse post(String path, String jsonBody) throws LscServiceException { return send("POST", path, jsonBody == null ? new byte[0] : jsonBody.getBytes(StandardCharsets.UTF_8)); } public HttpResponse put(String path, String jsonBody) throws LscServiceException { return send("PUT", path, jsonBody == null ? new byte[0] : jsonBody.getBytes(StandardCharsets.UTF_8)); } /** * Low-level send with retry and auth. {@code body} is the exact * payload bytes sent to the server; the same array is used to * compute the HMAC signature. */ public HttpResponse send(String method, String path, byte[] body) throws LscServiceException { Objects.requireNonNull(method, "method"); Objects.requireNonNull(path, "path"); URI uri = URI.create(baseUrl + path); IOException lastIo = null; HttpResponse last5xx = null; for (int attempt = 0; attempt <= retries; attempt++) { HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(uri) .timeout(Duration.ofMillis(timeoutMs)); byte[] bodyBytes = body == null ? new byte[0] : body; switch (method) { case "GET": builder.GET(); break; case "DELETE": builder.DELETE(); break; case "POST": builder.header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofByteArray(bodyBytes)); break; case "PUT": builder.header("Content-Type", "application/json") .PUT(HttpRequest.BodyPublishers.ofByteArray(bodyBytes)); break; default: throw new LscServiceException("Unsupported HTTP method: " + method); } builder.header("Accept", "application/json"); // Auth must run after Content-Type so headers are visible // but before send so the timestamp is fresh per attempt. auth.apply(builder, method, path, bodyBytes); HttpRequest req = builder.build(); try { HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); int code = resp.statusCode(); if (code >= 200 && code < 300) { return resp; } if (code >= 500 && attempt < retries) { last5xx = resp; long wait = backoffMillis(attempt); LOG.warn("ldap-rest {} {} returned {}, retrying in {}ms (attempt {}/{})", method, path, code, wait, attempt + 1, retries); sleeper.sleep(wait); continue; } // Non-retryable error throw new LscServiceException("ldap-rest " + method + " " + path + " failed with HTTP " + code + ": " + truncate(resp.body())); } catch (IOException ioe) { lastIo = ioe; if (attempt < retries) { long wait = backoffMillis(attempt); LOG.warn("ldap-rest {} {} I/O error: {}, retrying in {}ms (attempt {}/{})", method, path, ioe.getMessage(), wait, attempt + 1, retries); sleeper.sleep(wait); continue; } throw new LscServiceException("ldap-rest " + method + " " + path + " failed: " + ioe.getMessage(), ioe); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new LscServiceException("ldap-rest " + method + " " + path + " interrupted", ie); } } if (last5xx != null) { throw new LscServiceException("ldap-rest " + method + " " + path + " failed after " + retries + " retries with HTTP " + last5xx.statusCode() + ": " + truncate(last5xx.body())); } throw new LscServiceException("ldap-rest " + method + " " + path + " failed after " + retries + " retries", lastIo); } private static long backoffMillis(int attempt) { // 100ms, 200ms, 400ms, 800ms, capped at 5s long base = 100L * (1L << Math.min(attempt, 6)); return Math.min(base, 5000L); } private static String truncate(String s) { if (s == null) return ""; return s.length() > 500 ? s.substring(0, 500) + "..." : s; } private static String stripTrailingSlash(String s) { return s.endsWith("/") ? s.substring(0, s.length() - 1) : s; } /** Test seam for backoff sleep. */ @FunctionalInterface public interface Sleeper { void sleep(long ms); } } linagora-ldap-rest-16e557e/lsc-plugin/src/main/java/org/lscproject/ldaprest/LdapRestDstService.java000066400000000000000000000514121522642357000334160ustar00rootroot00000000000000/* * SPDX-License-Identifier: BSD-3-Clause */ package org.lscproject.ldaprest; import java.lang.reflect.Method; import java.net.http.HttpResponse; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import org.lsc.LscDatasetModification; import org.lsc.LscDatasetModification.LscDatasetModificationType; import org.lsc.LscDatasets; import org.lsc.LscModifications; import org.lsc.beans.IBean; import org.lsc.configuration.ConnectionType; import org.lsc.configuration.TaskType; import org.lsc.exception.LscServiceException; import org.lsc.service.IWritableService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; /** * LSC {@link IWritableService} that writes through the ldap-rest * HTTP API. * *

Configuration is read from the {@code } * element in {@code lsc.xml}; LSC hands us the parsed {@link TaskType} * and we extract our parameters from * {@code task.getPluginDestinationService().getAny()} (a list of DOM * elements). Recognised parameters:

* *
    *
  • {@code } — required, e.g. {@code https://ldap-rest.example.org}
  • *
  • {@code } — required, one of {@code users}, * {@code groups}, {@code organizations}, …
  • *
  • {@code } block containing either {@code } or * {@code }+{@code }
  • *
  • {@code } — optional, default 10000ms
  • *
  • {@code } — optional, default 3
  • *
  • {@code } — optional, default {@code /api}
  • *
* *

This service is purely destination: the read * methods ({@code getBean}, {@code getListPivots}) are stubbed * because LSC pulls source records from a separate source service * and only feeds us {@link LscModifications}. They return empty * structures so the framework does not crash if it ever calls * them.

*/ public class LdapRestDstService implements IWritableService { private static final Logger LOG = LoggerFactory.getLogger(LdapRestDstService.class); private final LdapRestClient client; private final ResourceMapper mapper; private final ModificationTranslator translator; private final List writeDatasetIds; /** * LSC constructor signature. Called once per task before sync. */ public LdapRestDstService(TaskType task) throws LscServiceException { Config cfg = parseConfig(task); this.mapper = new ResourceMapper(cfg.resourceType, cfg.apiPrefix); this.translator = new ModificationTranslator(this.mapper); this.client = new LdapRestClient(cfg.baseUrl, cfg.auth, cfg.timeoutMs, cfg.retries); this.writeDatasetIds = cfg.writeDatasetIds; LOG.info("ldap-rest plugin destination service initialised: baseUrl={}, resourceType={}, " + "auth={}, timeoutMs={}, retries={}", cfg.baseUrl, cfg.resourceType, cfg.auth.getClass().getSimpleName(), cfg.timeoutMs, cfg.retries); } /** Test-only constructor injecting an already-built client. */ LdapRestDstService(LdapRestClient client, ResourceMapper mapper) { this.client = client; this.mapper = mapper; this.translator = new ModificationTranslator(mapper); this.writeDatasetIds = Collections.emptyList(); } @Override public boolean apply(LscModifications lm) throws LscServiceException { if (lm == null || lm.getOperation() == null) { throw new LscServiceException("LscModifications has no operation"); } switch (lm.getOperation()) { case CREATE_OBJECT: return doCreate(lm); case UPDATE_OBJECT: return doUpdate(lm); case DELETE_OBJECT: return doDelete(lm); case CHANGE_ID: return doModrdn(lm); default: throw new LscServiceException("Unsupported LSC operation: " + lm.getOperation()); } } private boolean doCreate(LscModifications lm) throws LscServiceException { String body = translator.buildCreateBody(lm); String path = mapper.collectionPath(); HttpResponse resp = client.post(path, body); LOG.debug("CREATE {} -> {}", path, resp.statusCode()); return true; } private boolean doUpdate(LscModifications lm) throws LscServiceException { // For UPDATE on group, member-mutating attributes must use // the dedicated /members endpoints; ldap-rest itself enforces // this. We split the payload accordingly. if (mapper.getFamily() == ResourceMapper.Family.GROUPS) { return doGroupUpdate(lm); } String body = translator.buildUpdateBody(lm); String path = mapper.itemPath(lm.getMainIdentifier()); HttpResponse resp = client.put(path, body); LOG.debug("UPDATE {} -> {}", path, resp.statusCode()); return true; } /** * Group updates are special: ldap-rest forbids touching the * {@code member} attribute through PUT and exposes dedicated * member endpoints. We extract member ADD/DELETE operations * first, fire dedicated calls for each, then fall back to PUT * for non-member attribute changes. */ private boolean doGroupUpdate(LscModifications lm) throws LscServiceException { List rest = new ArrayList<>(); List addedMembers = new ArrayList<>(); List removedMembers = new ArrayList<>(); boolean replaceMembers = false; List replacedMembers = Collections.emptyList(); for (LscDatasetModification mod : safeList(lm.getLscAttributeModifications())) { if ("member".equalsIgnoreCase(mod.getAttributeName())) { List values = mod.getValues() == null ? Collections.emptyList() : mod.getValues(); switch (mod.getOperation()) { case ADD_VALUES: addedMembers.addAll(values); break; case DELETE_VALUES: if (values.isEmpty()) { // attribute-wide delete on `member` — best-effort // reconciliation: GET the group, list its current // members, and queue them all for deletion. If the // GET fails we surface a clear error rather than // silently no-op. List current = fetchCurrentMembers(lm.getMainIdentifier()); LOG.info("attribute-wide DELETE on group 'member' for {}: removing {} current member(s) via reconciliation", lm.getMainIdentifier(), current.size()); removedMembers.addAll(current); } else { removedMembers.addAll(values); } break; case REPLACE_VALUES: replaceMembers = true; replacedMembers = new ArrayList<>(values); break; default: break; } } else { rest.add(mod); } } // Apply replace-members as a (delete-all + add-all) sequence // because ldap-rest has no "replace members" endpoint. if (replaceMembers) { // We don't currently fetch the existing list (this service // is destination-only) so we approximate by removing the // explicit additions/removals first, then adding the new // set. Operators wanting a full reconciliation should use // ADD_VALUES/DELETE_VALUES explicitly in their sync map. LOG.warn("REPLACE on group 'member' is best-effort: missing members will not be removed." + " Use ADD_VALUES/DELETE_VALUES for deterministic group sync."); addedMembers.addAll(replacedMembers); } for (Object m : addedMembers) { String memberDn = String.valueOf(m); String path = mapper.membersPath(lm.getMainIdentifier()); Map body = new LinkedHashMap<>(); body.put("member", memberDn); client.post(path, ModificationTranslator.writeJson(body)); } for (Object m : removedMembers) { String memberDn = String.valueOf(m); String path = mapper.memberItemPath(lm.getMainIdentifier(), memberDn); client.delete(path); } if (!rest.isEmpty()) { LscModifications stripped = new LscModifications(lm.getOperation(), lm.getTaskName()); stripped.setMainIdentifer(lm.getMainIdentifier()); stripped.setNewMainIdentifier(lm.getNewMainIdentifier()); stripped.setLscAttributeModifications(rest); String body = translator.buildUpdateBody(stripped); // Avoid an empty PUT (no buckets) when only members changed. if (!"{}".equals(body)) { String path = mapper.itemPath(lm.getMainIdentifier()); client.put(path, body); } } return true; } /** * GET the group at {@code groupDn}, parse its JSON, and return the * current {@code member} list. Used to reconcile attribute-wide * DELETE on {@code member} when LSC emits no explicit value list. */ private List fetchCurrentMembers(String groupDn) throws LscServiceException { String path = mapper.itemPath(groupDn); HttpResponse resp = client.get(path); return ModificationTranslator.readMembers(resp.body()); } private boolean doDelete(LscModifications lm) throws LscServiceException { String path = mapper.itemPath(lm.getMainIdentifier()); try { HttpResponse resp = client.delete(path); LOG.debug("DELETE {} -> {}", path, resp.statusCode()); return true; } catch (LscServiceException e) { // 404 on DELETE = soft idempotence String msg = e.getMessage(); if (msg != null && msg.contains("HTTP 404")) { LOG.warn("DELETE {} returned 404 — already absent, treating as success", path); return true; } throw e; } } private boolean doModrdn(LscModifications lm) throws LscServiceException { ModificationTranslator.ModrdnPayload payload = translator.buildModrdnPayload(lm); String path; if (payload.kind == ModificationTranslator.ModrdnKind.RENAME) { path = mapper.renamePath(lm.getMainIdentifier()); } else { path = mapper.movePath(lm.getMainIdentifier()); } HttpResponse resp = client.post(path, payload.body); LOG.debug("MODRDN {} {} -> {}", payload.kind, path, resp.statusCode()); return true; } @Override public List getWriteDatasetIds() { return writeDatasetIds == null ? Collections.emptyList() : writeDatasetIds; } /** * Read-side: this service is destination-only. LSC requires the * methods to exist (because {@link IWritableService} extends * {@code IService}) but we are never the source. Returning empty * structures keeps LSC happy in defensive code paths. */ @Override public IBean getBean(String pivotName, LscDatasets pivotAttributes, boolean fromSameService) throws LscServiceException { LOG.debug("getBean({}) called on destination-only service — returning null", pivotName); return null; } @Override public Map getListPivots() throws LscServiceException { LOG.debug("getListPivots() called on destination-only service — returning empty map"); return new HashMap<>(); } @Override public Collection> getSupportedConnectionType() { // We do not bind to a typed LSC connection (no LDAP, no JDBC); // ldap-rest auth is configured inline. Return empty so LSC // doesn't try to inject one. return Collections.emptyList(); } // ------------------------------------------------------------------- // configuration parsing // ------------------------------------------------------------------- static class Config { String baseUrl; String resourceType; String apiPrefix = "/api"; long timeoutMs = 10_000L; int retries = 3; LdapRestAuth auth; List writeDatasetIds = new ArrayList<>(); } static Config parseConfig(TaskType task) throws LscServiceException { if (task == null) { throw new LscServiceException("ldap-rest plugin: TaskType is null"); } List elements = extractAnyElements(task); Config cfg = new Config(); Element authElement = null; for (Element e : elements) { String name = localName(e); String text = textOf(e).trim(); switch (name) { case "baseUrl": cfg.baseUrl = text; break; case "resourceType": cfg.resourceType = text; break; case "apiPrefix": if (!text.isEmpty()) cfg.apiPrefix = normaliseApiPrefix(text); break; case "timeoutMs": if (!text.isEmpty()) { try { cfg.timeoutMs = Long.parseLong(text); } catch (NumberFormatException nfe) { throw new LscServiceException("invalid : " + text); } if (cfg.timeoutMs <= 0) { throw new LscServiceException(" must be > 0, got: " + cfg.timeoutMs); } } break; case "retries": if (!text.isEmpty()) { try { cfg.retries = Integer.parseInt(text); } catch (NumberFormatException nfe) { throw new LscServiceException("invalid : " + text); } if (cfg.retries < 0) { throw new LscServiceException(" must be >= 0, got: " + cfg.retries); } } break; case "auth": authElement = e; break; case "writeDatasetIds": case "writeDatasetId": if (!text.isEmpty()) { for (String s : text.split(",")) { String t = s.trim(); if (!t.isEmpty()) cfg.writeDatasetIds.add(t); } } break; default: LOG.debug("ldap-rest plugin: ignoring unknown config element <{}>", name); } } if (cfg.baseUrl == null || cfg.baseUrl.isEmpty()) { throw new LscServiceException("ldap-rest plugin: is required"); } if (cfg.resourceType == null || cfg.resourceType.isEmpty()) { throw new LscServiceException("ldap-rest plugin: is required"); } cfg.auth = parseAuth(authElement); return cfg; } static LdapRestAuth parseAuth(Element authElement) throws LscServiceException { if (authElement == null) { throw new LscServiceException( "ldap-rest plugin: block missing — provide or " + "+"); } String bearer = childText(authElement, "bearer"); String svcId = childText(authElement, "hmacServiceId"); String secret = childText(authElement, "hmacSecret"); if (bearer != null && !bearer.isEmpty()) { return new BearerAuth(bearer); } if (svcId != null && secret != null && !svcId.isEmpty() && !secret.isEmpty()) { return new HmacAuth(svcId, secret); } throw new LscServiceException( "ldap-rest plugin: needs either or both " + " and "); } /** * Normalise {@code }: ensure leading {@code /}, strip * trailing {@code /}. So {@code "api"}, {@code "/api"}, {@code "/api/"} * all become {@code "/api"}. Also accepts the empty prefix {@code "/"}. */ static String normaliseApiPrefix(String raw) { String s = raw.trim(); if (s.isEmpty() || "/".equals(s)) return ""; if (!s.startsWith("/")) s = "/" + s; while (s.length() > 1 && s.endsWith("/")) s = s.substring(0, s.length() - 1); return s; } /** * Reflectively call {@code task.getPluginDestinationService().getAny()} * and filter out non-Element entries (whitespace text nodes, * comments, etc.). This keeps us compatible with subtle JAXB * binding differences across LSC versions. */ @SuppressWarnings("unchecked") static List extractAnyElements(TaskType task) throws LscServiceException { List out = new ArrayList<>(); try { Method getPlugin = task.getClass().getMethod("getPluginDestinationService"); Object plugin = getPlugin.invoke(task); if (plugin == null) { throw new LscServiceException( "ldap-rest plugin: not configured"); } Method getAny = plugin.getClass().getMethod("getAny"); Object any = getAny.invoke(plugin); if (any instanceof List) { for (Object o : (List) any) { if (o instanceof Element) { out.add((Element) o); } else if (o instanceof Node) { Node n = (Node) o; if (n.getNodeType() == Node.ELEMENT_NODE) { out.add((Element) n); } } } } else { LOG.warn("ldap-rest plugin: getAny() returned non-List ({}); ignoring", any == null ? "null" : any.getClass().getName()); } } catch (NoSuchMethodException nsme) { throw new LscServiceException( "ldap-rest plugin: incompatible LSC version (missing " + "getPluginDestinationService/getAny): " + nsme.getMessage(), nsme); } catch (ReflectiveOperationException roe) { throw new LscServiceException( "ldap-rest plugin: failed to read configuration: " + roe.getMessage(), roe); } return out; } static String localName(Element e) { String n = e.getLocalName(); if (n != null) return n; n = e.getNodeName(); int colon = n.indexOf(':'); return colon < 0 ? n : n.substring(colon + 1); } static String childText(Element parent, String childName) { NodeList kids = parent.getChildNodes(); for (int i = 0; i < kids.getLength(); i++) { Node n = kids.item(i); if (n.getNodeType() == Node.ELEMENT_NODE && childName.equalsIgnoreCase(localName((Element) n))) { return textOf((Element) n).trim(); } } return null; } static String textOf(Element e) { StringBuilder sb = new StringBuilder(); NodeList kids = e.getChildNodes(); for (int i = 0; i < kids.getLength(); i++) { Node n = kids.item(i); if (n.getNodeType() == Node.TEXT_NODE || n.getNodeType() == Node.CDATA_SECTION_NODE) { String v = n.getNodeValue(); if (v != null) sb.append(v); } } return sb.toString(); } private static List safeList(List l) { return l == null ? Collections.emptyList() : l; } /** Test seam to expose internals to the integration test. */ LdapRestClient client() { return client; } ResourceMapper mapper() { return mapper; } @SuppressWarnings("unused") private static String safeUpper(String s) { return s == null ? "" : s.toUpperCase(Locale.ROOT); } } ModificationTranslator.java000066400000000000000000000265611522642357000343130ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/src/main/java/org/lscproject/ldaprest/* * SPDX-License-Identifier: BSD-3-Clause */ package org.lscproject.ldaprest; import java.nio.charset.CharacterCodingException; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import org.lsc.LscDatasetModification; import org.lsc.LscDatasetModification.LscDatasetModificationType; import org.lsc.LscModifications; import org.lsc.exception.LscServiceException; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; /** * Convert {@link LscModifications} objects into JSON payloads * understood by ldap-rest. * *

The class uses a single {@link ObjectMapper} configured for * compact, deterministic output (no pretty-printing, insertion-order * preserving maps). This is critical because the same bytes that go * on the wire are also used to compute the HMAC signature; any * formatting change would break the server-side signature * verification.

* *

Mapping cheat-sheet:

*
    *
  • CREATE flat/org: a single JSON object whose keys are * the attribute names and values are either a scalar * (single-valued) or an array (multi-valued).
  • *
  • CREATE group: same shape, ldap-rest expects {@code cn}, * optional {@code description} and {@code member}.
  • *
  • UPDATE: an object with {@code add}, {@code replace} * and/or {@code delete} buckets, mirroring LSC's * {@link LscDatasetModificationType}.
  • *
  • MODRDN flat/org: {@code { "targetOrgDn": "..." }}.
  • *
  • MODRDN group: {@code { "newCn": "..." }}.
  • *
*/ public class ModificationTranslator { private static final ObjectMapper MAPPER = new ObjectMapper() .disable(SerializationFeature.INDENT_OUTPUT) // serializing Maps preserves insertion order by default; // we just need to ensure no other reordering kicks in. .disable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); private final ResourceMapper resourceMapper; public ModificationTranslator(ResourceMapper resourceMapper) { this.resourceMapper = resourceMapper; } /** * Build the body for a CREATE call (POST /collection). */ public String buildCreateBody(LscModifications lm) throws LscServiceException { Map body = new LinkedHashMap<>(); for (LscDatasetModification mod : safeList(lm.getLscAttributeModifications())) { if (mod.getOperation() == LscDatasetModificationType.DELETE_VALUES) { // CREATE shouldn't carry deletions; ignore defensively. continue; } Object val = collapse(mod.getAttributeName(), mod.getValues()); if (val != null) { body.put(mod.getAttributeName(), val); } } return writeJson(body); } /** * Build the body for an UPDATE call (PUT /collection/id). * *

Output shape: *

     *   {
     *     "replace": { attr: [v1, v2] },
     *     "add":     { attr: [v1] },
     *     "delete":  { attr: [v1] }   // value-targeted
     *               | ["attr1", "attr2"]   // attribute-wide
     *   }
     * 
* If both forms of {@code delete} appear, they are merged into * the value-targeted shape with {@code []} marking attribute-wide * deletes (an empty list is the convention used by ldap-rest's * server-side parser).

*/ public String buildUpdateBody(LscModifications lm) throws LscServiceException { Map> replace = new LinkedHashMap<>(); Map> add = new LinkedHashMap<>(); Map> deleteValues = new LinkedHashMap<>(); List deleteAttrs = new ArrayList<>(); for (LscDatasetModification mod : safeList(lm.getLscAttributeModifications())) { String name = mod.getAttributeName(); List vals = sanitizeValues(name, mod.getValues()); switch (mod.getOperation()) { case REPLACE_VALUES: replace.put(name, vals); break; case ADD_VALUES: add.put(name, vals); break; case DELETE_VALUES: if (vals.isEmpty()) { deleteAttrs.add(name); } else { deleteValues.put(name, vals); } break; default: // UNKNOWN — skip break; } } Map body = new LinkedHashMap<>(); if (!replace.isEmpty()) body.put("replace", replace); if (!add.isEmpty()) body.put("add", add); if (!deleteValues.isEmpty() || !deleteAttrs.isEmpty()) { if (deleteValues.isEmpty()) { body.put("delete", deleteAttrs); } else { // merge: attribute-wide deletes become attr -> [] entries for (String attr : deleteAttrs) { deleteValues.putIfAbsent(attr, Collections.emptyList()); } body.put("delete", deleteValues); } } return writeJson(body); } /** * Build the body for a MODRDN call. For flat/org resources the * payload is {@code { "targetOrgDn": newParent }}. For groups * with only an RDN change it is {@code { "newCn": newCn }}. * *

The translator picks the form based on the resource family * and on whether the new parent DN differs from the * current parent DN. If the parent DN is unchanged we treat it * as a pure rename (group only); if the parent changed we issue * a move.

* * @return a result object holding both the JSON body and the * endpoint hint (rename vs move) so the caller can pick * the right path. */ public ModrdnPayload buildModrdnPayload(LscModifications lm) throws LscServiceException { String oldDn = lm.getMainIdentifier(); String newDn = lm.getNewMainIdentifier(); if (newDn == null || newDn.isEmpty()) { throw new LscServiceException("MODRDN without newMainIdentifier on " + oldDn); } String oldParent = ResourceMapper.parentDn(oldDn == null ? "" : oldDn); String newParent = ResourceMapper.parentDn(newDn); boolean parentChanged = !oldParent.equalsIgnoreCase(newParent); if (resourceMapper.getFamily() == ResourceMapper.Family.GROUPS && !parentChanged) { String newCn = ResourceMapper.rdnValue(newDn); Map body = new LinkedHashMap<>(); body.put("newCn", newCn); return new ModrdnPayload(ModrdnKind.RENAME, writeJson(body)); } // flat/org or group with parent change: use /move Map body = new LinkedHashMap<>(); body.put("targetOrgDn", newParent); return new ModrdnPayload(ModrdnKind.MOVE, writeJson(body)); } /** Result of {@link #buildModrdnPayload(LscModifications)}. */ public static final class ModrdnPayload { public final ModrdnKind kind; public final String body; public ModrdnPayload(ModrdnKind kind, String body) { this.kind = kind; this.body = body; } } public enum ModrdnKind { MOVE, RENAME } // ------------------------------------------------------------------- // helpers // ------------------------------------------------------------------- /** * Collapse a values list to a JSON-friendly representation: * single value → scalar, multi-value → array, empty → {@code null}. * Fails on binary data. */ private static Object collapse(String attr, List values) throws LscServiceException { List sanitized = sanitizeValues(attr, values); if (sanitized.isEmpty()) return null; if (sanitized.size() == 1) return sanitized.get(0); return sanitized; } private static List sanitizeValues(String attr, List values) throws LscServiceException { List out = new ArrayList<>(); if (values == null) return out; for (Object v : values) { if (v == null) continue; if (v instanceof byte[]) { byte[] b = (byte[]) v; String s = decodeUtf8Strict(b); if (s == null) { throw new LscServiceException( "binary attributes not supported in this version: " + attr); } out.add(s); } else { out.add(v); } } return out; } /** * Try to decode bytes as strict UTF-8. Returns {@code null} if * the bytes do not form valid UTF-8 (which we treat as opaque * binary). */ private static String decodeUtf8Strict(byte[] bytes) { try { return StandardCharsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT) .decode(java.nio.ByteBuffer.wrap(bytes)) .toString(); } catch (CharacterCodingException e) { return null; } } private static List safeList(List list) { return list == null ? Collections.emptyList() : list; } static String writeJson(Object o) throws LscServiceException { try { return MAPPER.writeValueAsString(o); } catch (JsonProcessingException e) { throw new LscServiceException("failed to encode JSON payload: " + e.getMessage(), e); } } /** * Extract the {@code member} attribute values from a ldap-rest group * response body. Accepts the value as either an array of strings * (the common case) or a single string. Returns an empty list if * the response has no {@code member} field. */ static List readMembers(String json) throws LscServiceException { if (json == null || json.isEmpty()) return Collections.emptyList(); try { com.fasterxml.jackson.databind.JsonNode root = MAPPER.readTree(json); com.fasterxml.jackson.databind.JsonNode m = root.get("member"); if (m == null || m.isNull()) return Collections.emptyList(); List out = new ArrayList<>(); if (m.isArray()) { for (com.fasterxml.jackson.databind.JsonNode v : m) { if (v != null && !v.isNull()) out.add(v.asText()); } } else if (m.isTextual()) { out.add(m.asText()); } return out; } catch (JsonProcessingException e) { throw new LscServiceException("failed to parse group response JSON: " + e.getMessage(), e); } } /** * Lower-case a DN attribute type for comparison; trivial helper * exposed for {@link LdapRestDstService}. */ static String lowerType(String type) { return type == null ? "" : type.toLowerCase(Locale.ROOT); } } linagora-ldap-rest-16e557e/lsc-plugin/src/main/java/org/lscproject/ldaprest/ResourceMapper.java000066400000000000000000000161571522642357000326470ustar00rootroot00000000000000/* * SPDX-License-Identifier: BSD-3-Clause */ package org.lscproject.ldaprest; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.Locale; import java.util.Objects; import javax.naming.InvalidNameException; import javax.naming.ldap.LdapName; import javax.naming.ldap.Rdn; /** * Routes LSC operations to the right ldap-rest endpoint depending on * the configured resource type, and parses LSC-style DNs into * identifiers usable on the wire. * *

Three resource families are supported:

*
    *
  • {@code groups} — endpoints under {@code /groups}, identified * by the bare {@code cn} value.
  • *
  • {@code organizations} — endpoints under {@code /organizations}, * identified by the URL-encoded full DN.
  • *
  • everything else (e.g. {@code users}) — flat resources where * the identifier is the RDN value (e.g. {@code uid=alice}'s * {@code alice}).
  • *
*/ public class ResourceMapper { public enum Family { GROUPS, ORGANIZATIONS, FLAT } private final String resourceType; private final Family family; private final String apiPrefix; public ResourceMapper(String resourceType) { this(resourceType, "/api"); } public ResourceMapper(String resourceType, String apiPrefix) { this.resourceType = Objects.requireNonNull(resourceType, "resourceType").toLowerCase(Locale.ROOT); this.apiPrefix = Objects.requireNonNull(apiPrefix, "apiPrefix"); switch (this.resourceType) { case "groups": this.family = Family.GROUPS; break; case "organizations": this.family = Family.ORGANIZATIONS; break; default: this.family = Family.FLAT; } } public String getResourceType() { return resourceType; } public Family getFamily() { return family; } /** {@code /api/v1/ldap/{resource}} — for CREATE. */ public String collectionPath() { return apiPrefix + "/v1/ldap/" + resourceType; } /** {@code /api/v1/ldap/{resource}/{id}} — for UPDATE/DELETE. */ public String itemPath(String dn) { return collectionPath() + "/" + encodeId(dn); } /** {@code /api/v1/ldap/{resource}/{id}/move} — for MODRDN flat/org. */ public String movePath(String dn) { return itemPath(dn) + "/move"; } /** {@code /api/v1/ldap/groups/{cn}/rename} — for MODRDN groups. */ public String renamePath(String dn) { if (family != Family.GROUPS) { throw new IllegalStateException("rename is only valid for groups"); } return itemPath(dn) + "/rename"; } /** {@code /api/v1/ldap/groups/{cn}/members} — group member add. */ public String membersPath(String dn) { if (family != Family.GROUPS) { throw new IllegalStateException("members is only valid for groups"); } return itemPath(dn) + "/members"; } /** {@code /api/v1/ldap/groups/{cn}/members/{member}} — group member delete. */ public String memberItemPath(String groupDn, String memberDn) { if (family != Family.GROUPS) { throw new IllegalStateException("members is only valid for groups"); } return membersPath(groupDn) + "/" + encode(memberDn); } /** * Build the URL-suitable identifier from a LSC main identifier. * For groups/flat resources we want the bare RDN value (so * {@code uid=alice,ou=users,dc=...} → {@code alice}). For * organizations we want the entire DN URL-encoded. */ public String encodeId(String dn) { Objects.requireNonNull(dn, "dn"); switch (family) { case ORGANIZATIONS: return encode(dn); case GROUPS: case FLAT: default: return encode(rdnValue(dn)); } } /** * Extract the unescaped RDN value from a DN, parsed via * {@link javax.naming.ldap.LdapName} / {@link javax.naming.ldap.Rdn} * so all RFC 4514 / RFC 2253 escapes are handled correctly * ({@code \,}, {@code \\}, {@code \=}, hex {@code \xx}, leading/trailing * spaces, hex-string DER values, etc.). * *

Examples:

*
    *
  • {@code uid=alice,ou=users,dc=ex,dc=org} → {@code alice}
  • *
  • {@code cn=Doe\, John,ou=users,dc=ex,dc=org} → {@code Doe, John}
  • *
  • {@code cn=Doe\2c John,ou=users,dc=ex,dc=org} → {@code Doe, John}
  • *
  • {@code alice} → {@code alice} (bare value passthrough)
  • *
* *

The result is the *logical* identifier value that ldap-rest * expects in path params (the server re-escapes it itself via * {@code escapeDnValue}). Returning the escaped form would * double-escape on the wire.

*/ public static String rdnValue(String dn) { Objects.requireNonNull(dn, "dn"); String trimmed = dn.trim(); if (trimmed.isEmpty()) return trimmed; try { LdapName name = new LdapName(trimmed); if (name.isEmpty()) return trimmed; Rdn rdn = name.getRdn(name.size() - 1); Object v = rdn.getValue(); return v == null ? "" : v.toString(); } catch (InvalidNameException e) { // Bare value (e.g. "alice") is not a valid DN — fall through // and return it as-is. Same for anything LdapName refuses. return trimmed; } } /** * Return the parent DN (everything but the leftmost RDN), or {@code ""} * if {@code dn} has no parent (single RDN or bare value). Uses * {@link LdapName} so escape rules are RFC 4514 compliant. */ public static String parentDn(String dn) { Objects.requireNonNull(dn, "dn"); String trimmed = dn.trim(); if (trimmed.isEmpty()) return ""; try { LdapName name = new LdapName(trimmed); if (name.size() <= 1) return ""; return name.getPrefix(name.size() - 1).toString(); } catch (InvalidNameException e) { return ""; } } /** * Return the first RDN component (e.g. {@code uid=alice}) of a DN, * in its canonical RFC 4514 form. Used internally for diagnostics; * for the unescaped value alone use {@link #rdnValue(String)}. */ public static String firstRdn(String dn) { Objects.requireNonNull(dn, "dn"); String trimmed = dn.trim(); if (trimmed.isEmpty()) return ""; try { LdapName name = new LdapName(trimmed); if (name.isEmpty()) return trimmed; return name.getRdn(name.size() - 1).toString(); } catch (InvalidNameException e) { return trimmed; } } static String encode(String s) { return URLEncoder.encode(s, StandardCharsets.UTF_8) // URLEncoder uses '+' for spaces; ldap-rest decodes // percent-encoded form but not application/x-www-form-urlencoded // for path components, so use %20 to be safe. .replace("+", "%20"); } } linagora-ldap-rest-16e557e/lsc-plugin/src/test/000077500000000000000000000000001522642357000213735ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/src/test/java/000077500000000000000000000000001522642357000223145ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/src/test/java/org/000077500000000000000000000000001522642357000231035ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/src/test/java/org/lscproject/000077500000000000000000000000001522642357000252535ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/src/test/java/org/lscproject/ldaprest/000077500000000000000000000000001522642357000270715ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/src/test/java/org/lscproject/ldaprest/LdapRestAuthTest.java000066400000000000000000000131471522642357000331420ustar00rootroot00000000000000/* * SPDX-License-Identifier: BSD-3-Clause */ package org.lscproject.ldaprest; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.net.URI; import java.net.http.HttpRequest; import java.nio.charset.StandardCharsets; import java.util.Optional; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import org.junit.jupiter.api.Test; class LdapRestAuthTest { @Test void bearerSetsAuthorizationHeader() { BearerAuth auth = new BearerAuth("abc-123"); HttpRequest.Builder b = HttpRequest.newBuilder().uri(URI.create("http://x/y")); auth.apply(b, "POST", "/api/v1/ldap/users", "{\"x\":1}".getBytes(StandardCharsets.UTF_8)); HttpRequest req = b.GET().build(); Optional h = req.headers().firstValue("Authorization"); assertTrue(h.isPresent()); assertEquals("Bearer abc-123", h.get()); } /** * Cross-implementation contract test. The Node server in * test/plugins/auth/hmac.test.ts ("Cross-impl vector") hard-codes the * same expected signature. If either side drifts (signing string format, * body hashing rule, timestamp encoding), both tests fail. * * Vector: secret="test-secret-min-32-chars-long-xxx", method=POST, * path=/api/v1/ldap/users, ts=1700000000000, body={"uid":"alice"}. */ @Test void hmacCrossImplVector() { String secret = "test-secret-min-32-chars-long-xxx"; String method = "POST"; String path = "/api/v1/ldap/users"; long ts = 1_700_000_000_000L; byte[] body = "{\"uid\":\"alice\"}".getBytes(StandardCharsets.UTF_8); String expected = "65b065ff10ab2a54de0ab4db485c5744fcdd32a98e2fd24a8cef5240b43bbc94"; assertEquals(expected, HmacAuth.computeSignature(secret, method, path, ts, body), "HMAC signature must match the Node server's vector — see test/plugins/auth/hmac.test.ts"); } @Test void hmacReproducibleSignature() throws Exception { // Reference vector — values are also used by the cross-check // implemented by hand below. String secret = "test-secret-min-32-chars-long-xxx"; String serviceId = "lsc"; String method = "POST"; String path = "/api/v1/ldap/users"; // Body uses LinkedHashMap-style insertion order: {"uid":"alice"}. // That's exactly what the translator produces. String body = "{\"uid\":\"alice\"}"; long fixedTs = 1_700_000_000_000L; // Compute expected signature manually with javax.crypto.Mac String bodyHash = HmacAuth.sha256Hex(body.getBytes(StandardCharsets.UTF_8)); String signingString = method + "|" + path + "|" + fixedTs + "|" + bodyHash; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] expectedSig = mac.doFinal(signingString.getBytes(StandardCharsets.UTF_8)); StringBuilder hex = new StringBuilder(); for (byte b : expectedSig) hex.append(String.format("%02x", b)); String expectedHex = hex.toString(); // computeSignature should match String computed = HmacAuth.computeSignature(secret, method, path, fixedTs, body.getBytes(StandardCharsets.UTF_8)); assertEquals(expectedHex, computed, "computeSignature must match Mac directly"); // apply() must produce the same signature in the header HmacAuth auth = new HmacAuth(serviceId, secret, () -> fixedTs); HttpRequest.Builder b = HttpRequest.newBuilder().uri(URI.create("http://x" + path)); auth.apply(b, method, path, body.getBytes(StandardCharsets.UTF_8)); HttpRequest req = b.POST(HttpRequest.BodyPublishers.ofString(body)).build(); String headerValue = req.headers().firstValue("Authorization").orElseThrow(); assertEquals("HMAC-SHA256 " + serviceId + ":" + fixedTs + ":" + expectedHex, headerValue); } @Test void hmacUsesEmptyBodyHashForGet() { long ts = 1_700_000_000_000L; String secret = "secret"; String path = "/api/v1/ldap/users"; // For GET, bodyHash must be the empty string and body is ignored. String sigGet = HmacAuth.computeSignature(secret, "GET", path, ts, "ignored".getBytes()); // signing string is METHOD|PATH|TS| (with empty bodyHash) String expectedSigningString = "GET|" + path + "|" + ts + "|"; String expected = HmacAuth.hmacSha256Hex(secret, expectedSigningString); assertEquals(expected, sigGet); } @Test void hmacDeleteUsesEmptyBodyHash() { long ts = 42L; String secret = "k"; String path = "/api/v1/ldap/users/alice"; String sig = HmacAuth.computeSignature(secret, "DELETE", path, ts, null); String expected = HmacAuth.hmacSha256Hex(secret, "DELETE|" + path + "|" + ts + "|"); assertEquals(expected, sig); } @Test void sha256HexKnownVector() { // Empty input vector assertEquals("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", HmacAuth.sha256Hex(new byte[0])); // "abc" vector assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", HmacAuth.sha256Hex("abc".getBytes(StandardCharsets.UTF_8))); } @Test void hmacSignatureNonEmpty() { String sig = HmacAuth.computeSignature("key", "POST", "/x", 1L, "body".getBytes()); assertNotNull(sig); assertEquals(64, sig.length()); // 32 bytes hex } } linagora-ldap-rest-16e557e/lsc-plugin/src/test/java/org/lscproject/ldaprest/LdapRestClientTest.java000066400000000000000000000202251522642357000334520ustar00rootroot00000000000000/* * SPDX-License-Identifier: BSD-3-Clause */ package org.lscproject.ldaprest; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.delete; import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.post; import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.put; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.lsc.exception.LscServiceException; import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.core.WireMockConfiguration; class LdapRestClientTest { private WireMockServer server; private String baseUrl; @BeforeEach void start() { server = new WireMockServer(WireMockConfiguration.options().dynamicPort()); server.start(); baseUrl = "http://127.0.0.1:" + server.port(); } @AfterEach void stop() { if (server != null) server.stop(); } @Test void postSendsBodyAndAuthHeader() throws Exception { server.stubFor(post(urlEqualTo("/api/v1/ldap/users")) .willReturn(aResponse().withStatus(201).withBody("{\"ok\":true}"))); LdapRestClient c = new LdapRestClient(baseUrl, new BearerAuth("tok"), 5000, 0); HttpResponse r = c.post("/api/v1/ldap/users", "{\"uid\":\"alice\"}"); assertEquals(201, r.statusCode()); server.verify(postRequestedFor(urlEqualTo("/api/v1/ldap/users")) .withRequestBody(equalToJson("{\"uid\":\"alice\"}")) .withHeader("Authorization", equalTo("Bearer tok")) .withHeader("Content-Type", equalTo("application/json"))); } @Test void putSendsBody() throws Exception { server.stubFor(put(urlEqualTo("/api/v1/ldap/users/alice")) .willReturn(aResponse().withStatus(200).withBody("{}"))); LdapRestClient c = new LdapRestClient(baseUrl, new BearerAuth("t"), 5000, 0); HttpResponse r = c.put("/api/v1/ldap/users/alice", "{\"replace\":{\"sn\":[\"X\"]}}"); assertEquals(200, r.statusCode()); } @Test void deleteWithoutBody() throws Exception { server.stubFor(delete(urlEqualTo("/api/v1/ldap/users/alice")) .willReturn(aResponse().withStatus(200))); LdapRestClient c = new LdapRestClient(baseUrl, new BearerAuth("t"), 5000, 0); HttpResponse r = c.delete("/api/v1/ldap/users/alice"); assertEquals(200, r.statusCode()); } @Test void getWorks() throws Exception { server.stubFor(get(urlEqualTo("/api/v1/ldap/users")) .willReturn(aResponse().withStatus(200).withBody("[]"))); LdapRestClient c = new LdapRestClient(baseUrl, new BearerAuth("t"), 5000, 0); HttpResponse r = c.get("/api/v1/ldap/users"); assertEquals(200, r.statusCode()); } @Test void retriesOn5xxThenSucceeds() throws Exception { server.stubFor(post(urlEqualTo("/api/v1/ldap/users")) .inScenario("retry") .whenScenarioStateIs(com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED) .willReturn(aResponse().withStatus(503)) .willSetStateTo("after1")); server.stubFor(post(urlEqualTo("/api/v1/ldap/users")) .inScenario("retry") .whenScenarioStateIs("after1") .willReturn(aResponse().withStatus(503)) .willSetStateTo("after2")); server.stubFor(post(urlEqualTo("/api/v1/ldap/users")) .inScenario("retry") .whenScenarioStateIs("after2") .willReturn(aResponse().withStatus(201).withBody("{\"ok\":true}"))); LdapRestClient c = new LdapRestClient(baseUrl, new BearerAuth("t"), 5000, 3, HttpClient.newHttpClient(), ms -> { /* no-op */ }); HttpResponse r = c.post("/api/v1/ldap/users", "{}"); assertEquals(201, r.statusCode()); // 3 attempts total assertEquals(3, server.getAllServeEvents().size()); } @Test void retriesExhaustedOn5xxThrows() { server.stubFor(post(urlEqualTo("/api/v1/ldap/users")) .willReturn(aResponse().withStatus(500))); LdapRestClient c = new LdapRestClient(baseUrl, new BearerAuth("t"), 5000, 2, HttpClient.newHttpClient(), ms -> { /* no-op */ }); LscServiceException ex = assertThrows(LscServiceException.class, () -> c.post("/api/v1/ldap/users", "{}")); assertTrue(ex.getMessage().contains("HTTP 500")); } @Test void non2xxNon5xxFailsImmediately() { server.stubFor(post(urlEqualTo("/api/v1/ldap/users")) .willReturn(aResponse().withStatus(400).withBody("{\"error\":\"bad\"}"))); LdapRestClient c = new LdapRestClient(baseUrl, new BearerAuth("t"), 5000, 3, HttpClient.newHttpClient(), ms -> { /* no-op */ }); LscServiceException ex = assertThrows(LscServiceException.class, () -> c.post("/api/v1/ldap/users", "{}")); assertTrue(ex.getMessage().contains("HTTP 400")); // No retries: only 1 server call assertEquals(1, server.getAllServeEvents().size()); } @Test void timeoutCausesException() throws Exception { server.stubFor(post(urlEqualTo("/slow")) .willReturn(aResponse().withStatus(200).withFixedDelay(2000))); LdapRestClient c = new LdapRestClient(baseUrl, new BearerAuth("t"), 200, 0, HttpClient.newHttpClient(), ms -> { /* no-op */ }); assertThrows(LscServiceException.class, () -> c.post("/slow", "{}")); } @Test void hmacAuthHeaderIsPresent() throws Exception { server.stubFor(post(urlEqualTo("/api/v1/ldap/users")) .willReturn(aResponse().withStatus(201))); LdapRestClient c = new LdapRestClient(baseUrl, new HmacAuth("svc", "very-long-secret-32-chars-min-xxx", () -> 1234L), 5000, 0); c.post("/api/v1/ldap/users", "{\"uid\":\"x\"}"); server.verify(postRequestedFor(urlEqualTo("/api/v1/ldap/users")) .withHeader("Authorization", equalTo("HMAC-SHA256 svc:1234:" + HmacAuth.computeSignature( "very-long-secret-32-chars-min-xxx", "POST", "/api/v1/ldap/users", 1234L, "{\"uid\":\"x\"}".getBytes(StandardCharsets.UTF_8))))); } @Test void retriesOnIoExceptionThenThrows() { // Use an unbound port to force connection refused / IOException int port; try (java.net.ServerSocket s = new java.net.ServerSocket(0)) { port = s.getLocalPort(); } catch (IOException ioe) { throw new RuntimeException(ioe); } // socket closed → port unbound LdapRestClient c = new LdapRestClient("http://127.0.0.1:" + port, new BearerAuth("t"), 1000, 1, HttpClient.newHttpClient(), ms -> { /* no-op */ }); assertThrows(LscServiceException.class, () -> c.post("/x", "{}")); } @Test void baseUrlTrailingSlashIsStripped() throws Exception { server.stubFor(get(urlEqualTo("/x")).willReturn(aResponse().withStatus(200))); LdapRestClient c = new LdapRestClient(baseUrl + "/", new BearerAuth("t"), 5000, 0); HttpResponse r = c.get("/x"); assertEquals(200, r.statusCode()); } } LdapRestDstServiceConfigTest.java000066400000000000000000000201541522642357000353570ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/src/test/java/org/lscproject/ldaprest/* * SPDX-License-Identifier: BSD-3-Clause */ package org.lscproject.ldaprest; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.ByteArrayInputStream; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import org.junit.jupiter.api.Test; import org.lsc.configuration.PluginDestinationServiceType; import org.lsc.configuration.TaskType; import org.lsc.exception.LscServiceException; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NodeList; class LdapRestDstServiceConfigTest { /** * Build a TaskType where {@code getPluginDestinationService().getAny()} * returns the given DOM elements. */ private TaskType taskWithConfig(String xmlBody) throws Exception { String xml = "" + xmlBody + ""; DocumentBuilder db = DocumentBuilderFactory.newInstance().newDocumentBuilder(); Document doc = db.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); NodeList children = doc.getDocumentElement().getChildNodes(); List any = new ArrayList<>(); for (int i = 0; i < children.getLength(); i++) { if (children.item(i) instanceof Element) { any.add(children.item(i)); } } TaskType task = new TaskType(); PluginDestinationServiceType plugin = new PluginDestinationServiceType(); // Inject the parsed elements via reflection on the JAXB any list. // PluginDestinationServiceType.getAny() returns the underlying mutable list. Method getAny = plugin.getClass().getMethod("getAny"); @SuppressWarnings("unchecked") List live = (List) getAny.invoke(plugin); live.addAll(any); // task.setPluginDestinationService(plugin) task.setPluginDestinationService(plugin); return task; } @Test void parsesBaseUrlAndResourceTypeAndBearer() throws Exception { TaskType task = taskWithConfig( "https://api.test/" + "users" + "tok" + "2500" + "5"); LdapRestDstService.Config cfg = LdapRestDstService.parseConfig(task); assertEquals("https://api.test/", cfg.baseUrl); assertEquals("users", cfg.resourceType); assertEquals(2500L, cfg.timeoutMs); assertEquals(5, cfg.retries); assertTrue(cfg.auth instanceof BearerAuth); } @Test void parsesHmacAuth() throws Exception { TaskType task = taskWithConfig( "https://api.test" + "groups" + "" + " lsc" + " secret-32-chars-min-aaaaaaaaaaaaa" + ""); LdapRestDstService.Config cfg = LdapRestDstService.parseConfig(task); assertTrue(cfg.auth instanceof HmacAuth); } @Test void requiresBaseUrl() throws Exception { TaskType task = taskWithConfig( "users" + "t"); LscServiceException ex = assertThrows(LscServiceException.class, () -> LdapRestDstService.parseConfig(task)); assertTrue(ex.getMessage().contains("baseUrl")); } @Test void requiresResourceType() throws Exception { TaskType task = taskWithConfig( "https://api.test" + "t"); LscServiceException ex = assertThrows(LscServiceException.class, () -> LdapRestDstService.parseConfig(task)); assertTrue(ex.getMessage().contains("resourceType")); } @Test void requiresAuth() throws Exception { TaskType task = taskWithConfig( "https://api.test" + "users"); LscServiceException ex = assertThrows(LscServiceException.class, () -> LdapRestDstService.parseConfig(task)); assertTrue(ex.getMessage().contains("auth")); } @Test void requiresAuthCredentials() throws Exception { TaskType task = taskWithConfig( "https://api.test" + "users" + ""); LscServiceException ex = assertThrows(LscServiceException.class, () -> LdapRestDstService.parseConfig(task)); assertTrue(ex.getMessage().contains("bearer") || ex.getMessage().contains("hmac")); } @Test void defaultsAreApplied() throws Exception { TaskType task = taskWithConfig( "https://api.test" + "users" + "t"); LdapRestDstService.Config cfg = LdapRestDstService.parseConfig(task); assertEquals(10_000L, cfg.timeoutMs); assertEquals(3, cfg.retries); assertEquals("/api", cfg.apiPrefix); } @Test void rejectsZeroOrNegativeTimeout() throws Exception { for (String bad : new String[] {"0", "-1", "-10000"}) { TaskType task = taskWithConfig( "https://api.test" + "users" + "t" + "" + bad + ""); LscServiceException ex = assertThrows(LscServiceException.class, () -> LdapRestDstService.parseConfig(task), "expected rejection for timeoutMs=" + bad); assertTrue(ex.getMessage().contains("timeoutMs"), "message should mention timeoutMs, got: " + ex.getMessage()); } } @Test void rejectsNegativeRetries() throws Exception { TaskType task = taskWithConfig( "https://api.test" + "users" + "t" + "-1"); LscServiceException ex = assertThrows(LscServiceException.class, () -> LdapRestDstService.parseConfig(task)); assertTrue(ex.getMessage().contains("retries"), "message should mention retries, got: " + ex.getMessage()); } @Test void acceptsZeroRetries() throws Exception { // 0 retries is valid (= no retry, single attempt) TaskType task = taskWithConfig( "https://api.test" + "users" + "t" + "0"); LdapRestDstService.Config cfg = LdapRestDstService.parseConfig(task); assertEquals(0, cfg.retries); } @Test void rejectsNonNumericTimeout() throws Exception { TaskType task = taskWithConfig( "https://api.test" + "users" + "t" + "fast"); LscServiceException ex = assertThrows(LscServiceException.class, () -> LdapRestDstService.parseConfig(task)); assertTrue(ex.getMessage().contains("timeoutMs"), "message should mention timeoutMs, got: " + ex.getMessage()); } } LdapRestDstServiceIntegrationTest.java000066400000000000000000000324561522642357000364450ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/src/test/java/org/lscproject/ldaprest/* * SPDX-License-Identifier: BSD-3-Clause */ package org.lscproject.ldaprest; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.delete; import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.post; import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.put; import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Arrays; import java.util.Collections; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.lsc.LscDatasetModification; import org.lsc.LscDatasetModification.LscDatasetModificationType; import org.lsc.LscModificationType; import org.lsc.LscModifications; import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.core.WireMockConfiguration; class LdapRestDstServiceIntegrationTest { private WireMockServer server; private String baseUrl; @BeforeEach void start() { server = new WireMockServer(WireMockConfiguration.options().dynamicPort()); server.start(); baseUrl = "http://127.0.0.1:" + server.port(); } @AfterEach void stop() { if (server != null) server.stop(); } private LdapRestDstService usersService() { ResourceMapper mapper = new ResourceMapper("users"); LdapRestClient client = new LdapRestClient(baseUrl, new BearerAuth("t"), 5000, 0); return new LdapRestDstService(client, mapper); } private LdapRestDstService groupsService() { ResourceMapper mapper = new ResourceMapper("groups"); LdapRestClient client = new LdapRestClient(baseUrl, new BearerAuth("t"), 5000, 0); return new LdapRestDstService(client, mapper); } private LdapRestDstService orgsService() { ResourceMapper mapper = new ResourceMapper("organizations"); LdapRestClient client = new LdapRestClient(baseUrl, new BearerAuth("t"), 5000, 0); return new LdapRestDstService(client, mapper); } private LscModifications lm(LscModificationType op, String main) { LscModifications m = new LscModifications(op, "task"); m.setMainIdentifer(main); return m; } private LscDatasetModification rep(String name, Object... vals) { return new LscDatasetModification(LscDatasetModificationType.REPLACE_VALUES, name, Arrays.asList(vals)); } private LscDatasetModification add(String name, Object... vals) { return new LscDatasetModification(LscDatasetModificationType.ADD_VALUES, name, Arrays.asList(vals)); } private LscDatasetModification del(String name, Object... vals) { return new LscDatasetModification(LscDatasetModificationType.DELETE_VALUES, name, Arrays.asList(vals)); } @Test void createUserPostsCollection() throws Exception { server.stubFor(post(urlEqualTo("/api/v1/ldap/users")) .willReturn(aResponse().withStatus(201).withBody("{}"))); LscModifications m = lm(LscModificationType.CREATE_OBJECT, "uid=alice,ou=users,dc=ex,dc=org"); m.setLscAttributeModifications(Arrays.asList( rep("uid", "alice"), rep("sn", "Doe"), rep("cn", "Alice Doe"))); assertTrue(usersService().apply(m)); server.verify(postRequestedFor(urlEqualTo("/api/v1/ldap/users")) .withRequestBody(equalToJson("{\"uid\":\"alice\",\"sn\":\"Doe\",\"cn\":\"Alice Doe\"}")) .withHeader("Authorization", equalTo("Bearer t"))); } @Test void updateUserPutsItem() throws Exception { server.stubFor(put(urlEqualTo("/api/v1/ldap/users/alice")) .willReturn(aResponse().withStatus(200).withBody("{}"))); LscModifications m = lm(LscModificationType.UPDATE_OBJECT, "uid=alice,ou=users,dc=ex,dc=org"); m.setLscAttributeModifications(Arrays.asList( rep("sn", "NewName"), add("mail", "a@x.fr"), del("description"))); assertTrue(usersService().apply(m)); server.verify(putRequestedFor(urlEqualTo("/api/v1/ldap/users/alice")) .withRequestBody(equalToJson( "{\"replace\":{\"sn\":[\"NewName\"]},\"add\":{\"mail\":[\"a@x.fr\"]},\"delete\":[\"description\"]}"))); } @Test void deleteUser() throws Exception { server.stubFor(delete(urlEqualTo("/api/v1/ldap/users/alice")) .willReturn(aResponse().withStatus(200))); LscModifications m = lm(LscModificationType.DELETE_OBJECT, "uid=alice,ou=users,dc=ex,dc=org"); assertTrue(usersService().apply(m)); server.verify(deleteRequestedFor(urlEqualTo("/api/v1/ldap/users/alice"))); } @Test void deleteUser404IsTreatedAsSuccess() throws Exception { server.stubFor(delete(urlEqualTo("/api/v1/ldap/users/ghost")) .willReturn(aResponse().withStatus(404).withBody("{\"error\":\"not found\"}"))); LscModifications m = lm(LscModificationType.DELETE_OBJECT, "uid=ghost,ou=users,dc=ex,dc=org"); assertTrue(usersService().apply(m)); } @Test void modrdnUserCallsMove() throws Exception { server.stubFor(post(urlEqualTo("/api/v1/ldap/users/alice/move")) .willReturn(aResponse().withStatus(200))); LscModifications m = lm(LscModificationType.CHANGE_ID, "uid=alice,ou=users,dc=ex,dc=org"); m.setNewMainIdentifier("uid=alice,ou=admins,dc=ex,dc=org"); assertTrue(usersService().apply(m)); server.verify(postRequestedFor(urlEqualTo("/api/v1/ldap/users/alice/move")) .withRequestBody(equalToJson("{\"targetOrgDn\":\"ou=admins,dc=ex,dc=org\"}"))); } @Test void createGroupPostsCollection() throws Exception { server.stubFor(post(urlEqualTo("/api/v1/ldap/groups")) .willReturn(aResponse().withStatus(201))); LscModifications m = lm(LscModificationType.CREATE_OBJECT, "cn=admins,ou=groups,dc=ex,dc=org"); m.setLscAttributeModifications(Arrays.asList( rep("cn", "admins"), rep("description", "Admins"), rep("member", "uid=alice,ou=users,dc=ex,dc=org"))); assertTrue(groupsService().apply(m)); server.verify(postRequestedFor(urlEqualTo("/api/v1/ldap/groups")) .withRequestBody(equalToJson( "{\"cn\":\"admins\",\"description\":\"Admins\",\"member\":\"uid=alice,ou=users,dc=ex,dc=org\"}"))); } @Test void updateGroupAddMemberUsesMembersEndpoint() throws Exception { server.stubFor(post(urlEqualTo("/api/v1/ldap/groups/admins/members")) .willReturn(aResponse().withStatus(200))); LscModifications m = lm(LscModificationType.UPDATE_OBJECT, "cn=admins,ou=groups,dc=ex,dc=org"); m.setLscAttributeModifications(Collections.singletonList( add("member", "uid=alice,ou=users,dc=ex,dc=org"))); assertTrue(groupsService().apply(m)); server.verify(postRequestedFor(urlEqualTo("/api/v1/ldap/groups/admins/members")) .withRequestBody(equalToJson( "{\"member\":\"uid=alice,ou=users,dc=ex,dc=org\"}"))); } @Test void updateGroupDeleteMemberUsesMembersEndpoint() throws Exception { server.stubFor(delete(urlEqualTo("/api/v1/ldap/groups/admins/members/uid%3Dalice%2Cou%3Dusers%2Cdc%3Dex%2Cdc%3Dorg")) .willReturn(aResponse().withStatus(200))); LscModifications m = lm(LscModificationType.UPDATE_OBJECT, "cn=admins,ou=groups,dc=ex,dc=org"); m.setLscAttributeModifications(Collections.singletonList( del("member", "uid=alice,ou=users,dc=ex,dc=org"))); assertTrue(groupsService().apply(m)); } @Test void updateGroupAttributeWideDeleteOnMemberFetchesAndRemovesAll() throws Exception { // Reconcile: GET the group, parse "member" array, fire one DELETE per member. server.stubFor(get(urlEqualTo("/api/v1/ldap/groups/admins")) .willReturn(aResponse().withStatus(200).withBody( "{\"cn\":\"admins\",\"member\":[" + "\"uid=alice,ou=users,dc=ex,dc=org\"," + "\"uid=bob,ou=users,dc=ex,dc=org\"]}"))); server.stubFor(delete(urlEqualTo("/api/v1/ldap/groups/admins/members/uid%3Dalice%2Cou%3Dusers%2Cdc%3Dex%2Cdc%3Dorg")) .willReturn(aResponse().withStatus(200))); server.stubFor(delete(urlEqualTo("/api/v1/ldap/groups/admins/members/uid%3Dbob%2Cou%3Dusers%2Cdc%3Dex%2Cdc%3Dorg")) .willReturn(aResponse().withStatus(200))); LscModifications m = lm(LscModificationType.UPDATE_OBJECT, "cn=admins,ou=groups,dc=ex,dc=org"); // attribute-wide DELETE: empty values list m.setLscAttributeModifications(Collections.singletonList( new LscDatasetModification(LscDatasetModificationType.DELETE_VALUES, "member", Collections.emptyList()))); assertTrue(groupsService().apply(m)); server.verify(deleteRequestedFor(urlEqualTo( "/api/v1/ldap/groups/admins/members/uid%3Dalice%2Cou%3Dusers%2Cdc%3Dex%2Cdc%3Dorg"))); server.verify(deleteRequestedFor(urlEqualTo( "/api/v1/ldap/groups/admins/members/uid%3Dbob%2Cou%3Dusers%2Cdc%3Dex%2Cdc%3Dorg"))); } @Test void updateGroupNonMemberAttributeUsesPut() throws Exception { server.stubFor(put(urlEqualTo("/api/v1/ldap/groups/admins")) .willReturn(aResponse().withStatus(200))); LscModifications m = lm(LscModificationType.UPDATE_OBJECT, "cn=admins,ou=groups,dc=ex,dc=org"); m.setLscAttributeModifications(Collections.singletonList( rep("description", "New Description"))); assertTrue(groupsService().apply(m)); server.verify(putRequestedFor(urlEqualTo("/api/v1/ldap/groups/admins")) .withRequestBody(equalToJson( "{\"replace\":{\"description\":[\"New Description\"]}}"))); } @Test void deleteGroup() throws Exception { server.stubFor(delete(urlEqualTo("/api/v1/ldap/groups/admins")) .willReturn(aResponse().withStatus(200))); LscModifications m = lm(LscModificationType.DELETE_OBJECT, "cn=admins,ou=groups,dc=ex,dc=org"); assertTrue(groupsService().apply(m)); } @Test void renameGroupUsesRenameEndpoint() throws Exception { server.stubFor(post(urlEqualTo("/api/v1/ldap/groups/oldcn/rename")) .willReturn(aResponse().withStatus(200))); LscModifications m = lm(LscModificationType.CHANGE_ID, "cn=oldcn,ou=groups,dc=ex,dc=org"); m.setNewMainIdentifier("cn=newcn,ou=groups,dc=ex,dc=org"); assertTrue(groupsService().apply(m)); server.verify(postRequestedFor(urlEqualTo("/api/v1/ldap/groups/oldcn/rename")) .withRequestBody(equalToJson("{\"newCn\":\"newcn\"}"))); } @Test void createOrganizationPostsCollection() throws Exception { server.stubFor(post(urlEqualTo("/api/v1/ldap/organizations")) .willReturn(aResponse().withStatus(201))); LscModifications m = lm(LscModificationType.CREATE_OBJECT, "ou=sales,dc=ex,dc=org"); m.setLscAttributeModifications(Arrays.asList( rep("ou", "sales"), rep("description", "Sales"))); assertTrue(orgsService().apply(m)); server.verify(postRequestedFor(urlEqualTo("/api/v1/ldap/organizations")) .withRequestBody(equalToJson("{\"ou\":\"sales\",\"description\":\"Sales\"}"))); } @Test void deleteOrganizationUsesEncodedDn() throws Exception { server.stubFor(delete(urlEqualTo("/api/v1/ldap/organizations/ou%3Dsales%2Cdc%3Dex%2Cdc%3Dorg")) .willReturn(aResponse().withStatus(200))); LscModifications m = lm(LscModificationType.DELETE_OBJECT, "ou=sales,dc=ex,dc=org"); assertTrue(orgsService().apply(m)); } @Test void updateOrganizationPutsEncodedDn() throws Exception { server.stubFor(put(urlEqualTo("/api/v1/ldap/organizations/ou%3Dsales%2Cdc%3Dex%2Cdc%3Dorg")) .willReturn(aResponse().withStatus(200))); LscModifications m = lm(LscModificationType.UPDATE_OBJECT, "ou=sales,dc=ex,dc=org"); m.setLscAttributeModifications(Collections.singletonList(rep("description", "Up"))); assertTrue(orgsService().apply(m)); } @Test void readMethodsAreEmpty() throws Exception { LdapRestDstService svc = usersService(); assertEquals(null, svc.getBean("alice", new org.lsc.LscDatasets(), false)); assertTrue(svc.getListPivots().isEmpty()); assertTrue(svc.getSupportedConnectionType().isEmpty()); assertTrue(svc.getWriteDatasetIds().isEmpty()); } } ModificationTranslatorTest.java000066400000000000000000000346671522642357000352140ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/src/test/java/org/lscproject/ldaprest/* * SPDX-License-Identifier: BSD-3-Clause */ package org.lscproject.ldaprest; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import org.junit.jupiter.api.Test; import org.lsc.LscDatasetModification; import org.lsc.LscDatasetModification.LscDatasetModificationType; import org.lsc.LscModificationType; import org.lsc.LscModifications; import org.lsc.exception.LscServiceException; import com.fasterxml.jackson.databind.ObjectMapper; class ModificationTranslatorTest { private static final ObjectMapper M = new ObjectMapper(); private LscModifications mods(LscModificationType op, String mainId, LscDatasetModification... attrs) { LscModifications m = new LscModifications(op, "test-task"); m.setMainIdentifer(mainId); m.setLscAttributeModifications(Arrays.asList(attrs)); return m; } private LscDatasetModification attr(LscDatasetModificationType op, String name, Object... vals) { return new LscDatasetModification(op, name, Arrays.asList(vals)); } private Map parse(String json) throws Exception { return M.readValue(json, Map.class); } // ---------------- CREATE ---------------- @Test void createFlatSingleValue() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("users")); LscModifications lm = mods(LscModificationType.CREATE_OBJECT, "uid=alice,ou=users", attr(LscDatasetModificationType.REPLACE_VALUES, "uid", "alice"), attr(LscDatasetModificationType.REPLACE_VALUES, "sn", "Doe")); String json = t.buildCreateBody(lm); Map m = parse(json); assertEquals("alice", m.get("uid")); assertEquals("Doe", m.get("sn")); } @Test void createFlatMultiValueAsArray() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("users")); LscModifications lm = mods(LscModificationType.CREATE_OBJECT, "uid=bob", attr(LscDatasetModificationType.REPLACE_VALUES, "objectClass", "top", "person")); String json = t.buildCreateBody(lm); Map m = parse(json); assertEquals(Arrays.asList("top", "person"), m.get("objectClass")); } @Test void createGroup() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("groups")); LscModifications lm = mods(LscModificationType.CREATE_OBJECT, "cn=admins,ou=groups", attr(LscDatasetModificationType.REPLACE_VALUES, "cn", "admins"), attr(LscDatasetModificationType.REPLACE_VALUES, "description", "Administrators"), attr(LscDatasetModificationType.REPLACE_VALUES, "member", "uid=alice,ou=users", "uid=bob,ou=users")); String json = t.buildCreateBody(lm); Map m = parse(json); assertEquals("admins", m.get("cn")); assertEquals("Administrators", m.get("description")); assertEquals(Arrays.asList("uid=alice,ou=users", "uid=bob,ou=users"), m.get("member")); } @Test void createOrganization() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("organizations")); LscModifications lm = mods(LscModificationType.CREATE_OBJECT, "ou=sales,dc=ex,dc=org", attr(LscDatasetModificationType.REPLACE_VALUES, "ou", "sales"), attr(LscDatasetModificationType.REPLACE_VALUES, "description", "Sales team")); String json = t.buildCreateBody(lm); Map m = parse(json); assertEquals("sales", m.get("ou")); assertEquals("Sales team", m.get("description")); } // ---------------- UPDATE ---------------- @Test void updateReplaceOnly() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("users")); LscModifications lm = mods(LscModificationType.UPDATE_OBJECT, "uid=alice,ou=users", attr(LscDatasetModificationType.REPLACE_VALUES, "sn", "NewName")); Map m = parse(t.buildUpdateBody(lm)); assertTrue(m.containsKey("replace")); Map rep = (Map) m.get("replace"); assertEquals(Collections.singletonList("NewName"), rep.get("sn")); assertTrue(!m.containsKey("add")); assertTrue(!m.containsKey("delete")); } @Test void updateAddOnly() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("users")); LscModifications lm = mods(LscModificationType.UPDATE_OBJECT, "uid=alice", attr(LscDatasetModificationType.ADD_VALUES, "mail", "a@x.fr")); Map m = parse(t.buildUpdateBody(lm)); assertTrue(m.containsKey("add")); Map add = (Map) m.get("add"); assertEquals(Collections.singletonList("a@x.fr"), add.get("mail")); } @Test void updateDeleteAttributeWide() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("users")); LscModifications lm = mods(LscModificationType.UPDATE_OBJECT, "uid=alice", new LscDatasetModification(LscDatasetModificationType.DELETE_VALUES, "telephoneNumber", Collections.emptyList())); Map m = parse(t.buildUpdateBody(lm)); assertEquals(Collections.singletonList("telephoneNumber"), m.get("delete")); } @Test void updateDeleteSpecificValues() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("users")); LscModifications lm = mods(LscModificationType.UPDATE_OBJECT, "uid=alice", attr(LscDatasetModificationType.DELETE_VALUES, "mail", "old@x.fr")); Map m = parse(t.buildUpdateBody(lm)); Map del = (Map) m.get("delete"); assertEquals(Collections.singletonList("old@x.fr"), del.get("mail")); } @Test void updateMixReplaceAddDelete() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("users")); LscModifications lm = mods(LscModificationType.UPDATE_OBJECT, "uid=alice", attr(LscDatasetModificationType.REPLACE_VALUES, "sn", "Doe"), attr(LscDatasetModificationType.ADD_VALUES, "mail", "n@x.fr"), attr(LscDatasetModificationType.DELETE_VALUES, "mail", "o@x.fr"), new LscDatasetModification(LscDatasetModificationType.DELETE_VALUES, "telephoneNumber", Collections.emptyList())); Map m = parse(t.buildUpdateBody(lm)); assertTrue(m.containsKey("replace")); assertTrue(m.containsKey("add")); // delete merged: telephoneNumber → [], mail → [o@x.fr] Map del = (Map) m.get("delete"); assertEquals(Collections.singletonList("o@x.fr"), del.get("mail")); assertEquals(Collections.emptyList(), del.get("telephoneNumber")); } @Test void updateGroupShape() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("groups")); LscModifications lm = mods(LscModificationType.UPDATE_OBJECT, "cn=admins", attr(LscDatasetModificationType.REPLACE_VALUES, "description", "New desc")); Map m = parse(t.buildUpdateBody(lm)); Map rep = (Map) m.get("replace"); assertEquals(Collections.singletonList("New desc"), rep.get("description")); } @Test void updateOrgShape() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("organizations")); LscModifications lm = mods(LscModificationType.UPDATE_OBJECT, "ou=sales,dc=ex,dc=org", attr(LscDatasetModificationType.REPLACE_VALUES, "description", "Updated")); Map m = parse(t.buildUpdateBody(lm)); assertTrue(m.containsKey("replace")); } // ---------------- MODRDN ---------------- @Test void modrdnFlatProducesMoveTargetOrgDn() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("users")); LscModifications lm = mods(LscModificationType.CHANGE_ID, "uid=alice,ou=users,dc=ex,dc=org"); lm.setNewMainIdentifier("uid=alice,ou=admins,dc=ex,dc=org"); ModificationTranslator.ModrdnPayload p = t.buildModrdnPayload(lm); assertEquals(ModificationTranslator.ModrdnKind.MOVE, p.kind); Map m = parse(p.body); assertEquals("ou=admins,dc=ex,dc=org", m.get("targetOrgDn")); } @Test void modrdnGroupSameParentProducesRename() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("groups")); LscModifications lm = mods(LscModificationType.CHANGE_ID, "cn=oldname,ou=groups,dc=ex,dc=org"); lm.setNewMainIdentifier("cn=newname,ou=groups,dc=ex,dc=org"); ModificationTranslator.ModrdnPayload p = t.buildModrdnPayload(lm); assertEquals(ModificationTranslator.ModrdnKind.RENAME, p.kind); Map m = parse(p.body); assertEquals("newname", m.get("newCn")); } @Test void modrdnGroupDifferentParentProducesMove() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("groups")); LscModifications lm = mods(LscModificationType.CHANGE_ID, "cn=admins,ou=groups,dc=ex,dc=org"); lm.setNewMainIdentifier("cn=admins,ou=other,dc=ex,dc=org"); ModificationTranslator.ModrdnPayload p = t.buildModrdnPayload(lm); assertEquals(ModificationTranslator.ModrdnKind.MOVE, p.kind); } @Test void modrdnOrgProducesMove() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("organizations")); LscModifications lm = mods(LscModificationType.CHANGE_ID, "ou=sales,dc=ex,dc=org"); lm.setNewMainIdentifier("ou=sales,ou=parent,dc=ex,dc=org"); ModificationTranslator.ModrdnPayload p = t.buildModrdnPayload(lm); assertEquals(ModificationTranslator.ModrdnKind.MOVE, p.kind); Map m = parse(p.body); assertEquals("ou=parent,dc=ex,dc=org", m.get("targetOrgDn")); } @Test void modrdnFailsWithoutNewMainIdentifier() { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("users")); LscModifications lm = mods(LscModificationType.CHANGE_ID, "uid=alice"); assertThrows(LscServiceException.class, () -> t.buildModrdnPayload(lm)); } // ---------------- BINARY ---------------- @Test void binaryAttributeRejected() { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("users")); byte[] notUtf8 = new byte[] { (byte) 0xff, (byte) 0xfe, (byte) 0xc3, 0x28 }; LscModifications lm = mods(LscModificationType.CREATE_OBJECT, "uid=alice", new LscDatasetModification(LscDatasetModificationType.REPLACE_VALUES, "jpegPhoto", java.util.Collections.singletonList(notUtf8))); LscServiceException ex = assertThrows(LscServiceException.class, () -> t.buildCreateBody(lm)); assertTrue(ex.getMessage().contains("binary")); assertTrue(ex.getMessage().contains("jpegPhoto")); } @Test void utf8BinaryIsAcceptedAsString() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("users")); byte[] utf8 = "héllo".getBytes(java.nio.charset.StandardCharsets.UTF_8); LscModifications lm = mods(LscModificationType.CREATE_OBJECT, "uid=alice", new LscDatasetModification(LscDatasetModificationType.REPLACE_VALUES, "description", java.util.Collections.singletonList(utf8))); Map m = parse(t.buildCreateBody(lm)); assertEquals("héllo", m.get("description")); } // ---------------- JSON shape (signature stability) ---------------- @Test void jsonOutputIsCompactNoExtraSpaces() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("users")); LscModifications lm = mods(LscModificationType.CREATE_OBJECT, "uid=alice", attr(LscDatasetModificationType.REPLACE_VALUES, "uid", "alice")); String json = t.buildCreateBody(lm); // No pretty print: no spaces after ':' or ',' assertEquals("{\"uid\":\"alice\"}", json); } @Test void jsonOutputPreservesInsertionOrder() throws Exception { ModificationTranslator t = new ModificationTranslator(new ResourceMapper("users")); // Insertion order matters for HMAC stability LscModifications lm = mods(LscModificationType.CREATE_OBJECT, "uid=alice", attr(LscDatasetModificationType.REPLACE_VALUES, "z", "1"), attr(LscDatasetModificationType.REPLACE_VALUES, "a", "2")); assertEquals("{\"z\":\"1\",\"a\":\"2\"}", t.buildCreateBody(lm)); } @Test void writeJsonHelperIsCompact() throws Exception { Map body = new LinkedHashMap<>(); body.put("a", 1); body.put("b", "two"); assertEquals("{\"a\":1,\"b\":\"two\"}", ModificationTranslator.writeJson(body)); } @Test void readMembersFromArray() throws Exception { java.util.List members = ModificationTranslator.readMembers( "{\"cn\":\"admins\",\"member\":[\"uid=alice,ou=users\",\"uid=bob,ou=users\"]}"); assertEquals(2, members.size()); assertEquals("uid=alice,ou=users", members.get(0)); assertEquals("uid=bob,ou=users", members.get(1)); } @Test void readMembersFromSingleString() throws Exception { java.util.List members = ModificationTranslator.readMembers( "{\"member\":\"uid=alice,ou=users\"}"); assertEquals(1, members.size()); assertEquals("uid=alice,ou=users", members.get(0)); } @Test void readMembersHandlesMissingField() throws Exception { assertEquals(0, ModificationTranslator.readMembers("{\"cn\":\"admins\"}").size()); assertEquals(0, ModificationTranslator.readMembers("{}").size()); assertEquals(0, ModificationTranslator.readMembers("").size()); assertEquals(0, ModificationTranslator.readMembers(null).size()); } } linagora-ldap-rest-16e557e/lsc-plugin/src/test/java/org/lscproject/ldaprest/ResourceMapperTest.java000066400000000000000000000114401522642357000335300ustar00rootroot00000000000000/* * SPDX-License-Identifier: BSD-3-Clause */ package org.lscproject.ldaprest; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; class ResourceMapperTest { @Test void rdnValueExtractsSimpleUid() { assertEquals("alice", ResourceMapper.rdnValue("uid=alice,ou=users,dc=ex,dc=org")); } @Test void rdnValueExtractsCn() { assertEquals("admins", ResourceMapper.rdnValue("cn=admins,ou=groups,dc=ex,dc=org")); } @Test void rdnValueOnBareValue() { assertEquals("alice", ResourceMapper.rdnValue("alice")); } @Test void rdnValueUnescapesRfc4514EscapedComma() { // ldap-rest expects the unescaped logical value in :id path params; // the server re-escapes it via escapeDnValue. Returning the still- // escaped form would double-escape on the wire. assertEquals("Doe, John", ResourceMapper.rdnValue("cn=Doe\\, John,ou=users,dc=ex,dc=org")); } @Test void rdnValueUnescapesHexEscape() { // \2c is an RFC 4514 hex escape for ',' assertEquals("Doe, John", ResourceMapper.rdnValue("cn=Doe\\2c John,ou=users,dc=ex,dc=org")); } @Test void rdnValueUnescapesBackslash() { assertEquals("a\\b", ResourceMapper.rdnValue("cn=a\\\\b,ou=x")); } @Test void rdnValueUnescapesPlus() { // \+ is the RFC 4514 escape for '+' (which separates multi-valued RDNs) assertEquals("a+b", ResourceMapper.rdnValue("cn=a\\+b,ou=x")); } @Test void rdnValueUnescapesEquals() { assertEquals("foo=bar", ResourceMapper.rdnValue("cn=foo\\=bar,ou=x")); } @Test void rdnValueExtractsLeftmostFromMultiValuedRdn() { // Multi-valued RDN: "cn=A+sn=B,...". LdapName parses both as one // RDN; we extract the value picked up by Rdn.getValue() (the // first attribute defined in the RDN). String got = ResourceMapper.rdnValue("cn=Smith+sn=John,ou=users,dc=ex,dc=org"); // Either "Smith" or "John" depending on JDK ordering; both are // legitimate logical identifiers for this entry. org.junit.jupiter.api.Assertions.assertTrue( "Smith".equals(got) || "John".equals(got), "expected Smith or John, got: " + got); } @Test void parentDnReturnsTail() { assertEquals("ou=users,dc=ex,dc=org", ResourceMapper.parentDn("uid=alice,ou=users,dc=ex,dc=org")); } @Test void parentDnEmptyForRdnOnly() { assertEquals("", ResourceMapper.parentDn("uid=alice")); } @Test void firstRdnHandlesEscapedComma() { assertEquals("cn=Doe\\, John", ResourceMapper.firstRdn("cn=Doe\\, John,ou=users,dc=ex,dc=org")); } @Test void collectionPathFlat() { ResourceMapper m = new ResourceMapper("users"); assertEquals("/api/v1/ldap/users", m.collectionPath()); } @Test void itemPathFlatUsesRdnValue() { ResourceMapper m = new ResourceMapper("users"); assertEquals("/api/v1/ldap/users/alice", m.itemPath("uid=alice,ou=users,dc=ex,dc=org")); } @Test void itemPathOrgUsesEncodedDn() { ResourceMapper m = new ResourceMapper("organizations"); String got = m.itemPath("ou=sales,dc=ex,dc=org"); // commas and equals are URL-encoded assertEquals("/api/v1/ldap/organizations/ou%3Dsales%2Cdc%3Dex%2Cdc%3Dorg", got); } @Test void movePathFlat() { ResourceMapper m = new ResourceMapper("users"); assertEquals("/api/v1/ldap/users/alice/move", m.movePath("uid=alice,ou=users")); } @Test void renamePathOnlyForGroups() { ResourceMapper g = new ResourceMapper("groups"); assertEquals("/api/v1/ldap/groups/admins/rename", g.renamePath("cn=admins,ou=groups")); ResourceMapper u = new ResourceMapper("users"); assertThrows(IllegalStateException.class, () -> u.renamePath("uid=x")); } @Test void membersPath() { ResourceMapper g = new ResourceMapper("groups"); assertEquals("/api/v1/ldap/groups/admins/members", g.membersPath("cn=admins,ou=groups")); } @Test void memberItemPathEncodesMemberDn() { ResourceMapper g = new ResourceMapper("groups"); String p = g.memberItemPath("cn=admins,ou=groups", "uid=alice,ou=users,dc=ex,dc=org"); assertEquals( "/api/v1/ldap/groups/admins/members/uid%3Dalice%2Cou%3Dusers%2Cdc%3Dex%2Cdc%3Dorg", p); } @Test void resourceTypeIsCaseInsensitive() { ResourceMapper m = new ResourceMapper("Groups"); assertEquals(ResourceMapper.Family.GROUPS, m.getFamily()); } } linagora-ldap-rest-16e557e/lsc-plugin/test/000077500000000000000000000000001522642357000206045ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/test/integration/000077500000000000000000000000001522642357000231275ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/test/integration/Dockerfile.lsc000066400000000000000000000033231522642357000257020ustar00rootroot00000000000000# LSC runner image with the ldap-rest plugin preinstalled. # Builds LSC from source (release tag v2.2) since lsc-project/lsc is not # published to Maven Central and there is no maintained official Docker image. FROM eclipse-temurin:11-jdk AS lsc-build RUN apt-get update && apt-get install -y --no-install-recommends \ git ca-certificates maven \ && rm -rf /var/lib/apt/lists/* RUN git clone --depth 1 --branch v2.2 https://github.com/lsc-project/lsc.git /src/lsc WORKDIR /src/lsc RUN mvn -B -DskipTests package FROM eclipse-temurin:11-jdk AS plugin-build RUN apt-get update && apt-get install -y --no-install-recommends maven \ && rm -rf /var/lib/apt/lists/* COPY --from=lsc-build /root/.m2 /root/.m2 COPY --from=lsc-build /src/lsc /src/lsc RUN cd /src/lsc && mvn -B -DskipTests install COPY pom.xml /src/plugin/pom.xml COPY src /src/plugin/src WORKDIR /src/plugin RUN mvn -B -DskipTests package FROM eclipse-temurin:11-jre RUN apt-get update && apt-get install -y --no-install-recommends \ ldap-utils curl jq ca-certificates \ && rm -rf /var/lib/apt/lists/* # LSC distribution layout: /opt/lsc/{bin,lib,etc} ENV LSC_HOME=/opt/lsc RUN mkdir -p $LSC_HOME/bin $LSC_HOME/lib $LSC_HOME/etc # Copy LSC core jars + dependencies COPY --from=lsc-build /src/lsc/target/lsc-core-2.2.jar $LSC_HOME/lib/ COPY --from=lsc-build /src/lsc/target/dependency/ $LSC_HOME/lib/ # Copy the lsc launcher script if present in the build, or provide a wrapper COPY --from=lsc-build /src/lsc/bin/lsc $LSC_HOME/bin/lsc # Copy our plugin (with-deps shaded jar so Jackson is bundled) COPY --from=plugin-build /src/plugin/target/lsc-ldaprest-plugin-0.1.0-SNAPSHOT-with-deps.jar $LSC_HOME/lib/ ENV PATH="$LSC_HOME/bin:$PATH" WORKDIR $LSC_HOME linagora-ldap-rest-16e557e/lsc-plugin/test/integration/README.md000066400000000000000000000042311522642357000244060ustar00rootroot00000000000000# Integration test for lsc-ldaprest-plugin End-to-end test: a real LSC binary syncs entries from a source OpenLDAP instance to ldap-rest (which writes them into a target OpenLDAP), through the plugin under test. No mocks. ## Stack | Service | Image | Role | | ------------- | -------------------------------- | -------------------------------------------------- | | `ldap-source` | `osixia/openldap:1.5.0` | LDAP source seeded from `ldif/source-seed.ldif` | | `ldap-target` | `osixia/openldap:1.5.0` | Empty LDAP target written by ldap-rest | | `ldap-rest` | built from `../../../Dockerfile` | ldap-rest service, Bearer auth, `/api/v1/ldap/...` | | `lsc` | built from `Dockerfile.lsc` | LSC v2.2 + plugin jar in classpath | ## Run ```sh cd lsc-plugin/test/integration ./tests/run.sh ``` The first run downloads images and builds three containers, including LSC from source — count 5–10 minutes. Subsequent runs are much faster. Container logs are dumped to `logs/` after each run. The compose stack is torn down (volumes wiped) on exit, success or failure. ## Scenarios 1. **CREATE** — first sync of 5 users from source. Asserts: - `GET /api/v1/ldap/users` returns 5 entries - `GET /api/v1/ldap/users/alice` matches the source attributes - direct `ldapsearch` on `ldap-target` finds alice (proves ldap-rest actually wrote LDAP, not just acked) 2. **UPDATE** — modify alice's `mail` in the source, re-sync. Asserts the change propagated through the plugin's PUT `replace` translation. 3. **DELETE** — remove eve from the source, re-sync. Asserts `GET /users/eve` returns 404. Future additions: groups CRUD, organizations CRUD, RENAME (modrdn), HMAC auth variant. ## Notes - The plugin's `` reference (`dst-ldap-rest-stub`) points to ldap-target only to satisfy LSC schema validation. The plugin ignores this binding at runtime and uses `` + Bearer token instead. - `DM_AUTHZ_PER_BRANCH_CONFIG` is wide-open in this test env. Production setups should scope writes to the LSC sync subtree. linagora-ldap-rest-16e557e/lsc-plugin/test/integration/docker-compose.yml000066400000000000000000000067531522642357000265770ustar00rootroot00000000000000version: '3.8' # End-to-end integration test stack for the lsc-ldaprest-plugin. # Spins up: # - ldap-source : OpenLDAP populated from ldif/source-seed.ldif (LSC reads from this) # - ldap-target : empty OpenLDAP, written to by ldap-rest # - ldap-rest : ldap-rest service backed by ldap-target, Bearer token auth # - lsc : LSC + the plugin jar, runs the sync task on demand services: ldap-source: image: osixia/openldap:1.5.0 container_name: lsc-it-ldap-source environment: LDAP_ORGANISATION: 'Source Org' LDAP_DOMAIN: 'source.example.com' LDAP_ADMIN_PASSWORD: 'admin' LDAP_TLS: 'false' volumes: - ./ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom:ro healthcheck: test: [ 'CMD', 'ldapsearch', '-x', '-H', 'ldap://localhost', '-b', 'dc=source,dc=example,dc=com', '-D', 'cn=admin,dc=source,dc=example,dc=com', '-w', 'admin', ] interval: 5s timeout: 5s retries: 20 networks: - lsc-it ldap-target: image: osixia/openldap:1.5.0 container_name: lsc-it-ldap-target environment: LDAP_ORGANISATION: 'Target Org' LDAP_DOMAIN: 'target.example.com' LDAP_ADMIN_PASSWORD: 'admin' LDAP_TLS: 'false' healthcheck: test: [ 'CMD', 'ldapsearch', '-x', '-H', 'ldap://localhost', '-b', 'dc=target,dc=example,dc=com', '-D', 'cn=admin,dc=target,dc=example,dc=com', '-w', 'admin', ] interval: 5s timeout: 5s retries: 20 networks: - lsc-it ldap-rest: build: context: ../../.. dockerfile: Dockerfile container_name: lsc-it-ldap-rest depends_on: ldap-target: condition: service_healthy environment: DM_LDAP_URL: 'ldap://ldap-target:389' DM_LDAP_BASE: 'dc=target,dc=example,dc=com' DM_LDAP_DN: 'cn=admin,dc=target,dc=example,dc=com' DM_LDAP_PWD: 'admin' DM_LDAP_USER_ATTRIBUTE: 'uid' DM_LDAP_USERS_BASE: 'ou=users,dc=target,dc=example,dc=com' DM_LDAP_GROUP_BASE: 'ou=groups,dc=target,dc=example,dc=com' DM_LDAP_TOP_ORGANIZATION: 'dc=target,dc=example,dc=com' DM_MAIL_DOMAIN: 'target.example.com' DM_PORT: '8081' DM_LOG_LEVEL: 'info' DM_PLUGINS: 'core/static,core/auth/token,core/ldap/flatGeneric,core/ldap/groups,core/ldap/organization' DM_API_PREFIX: '/api' DM_LDAP_FLAT_SCHEMA: '/app/node_modules/ldap-rest/static/schemas/standard/users.json' DM_AUTH_TOKENS: 'lsc-it-token-please-change-me-32chars:lsc-tester' DM_AUTHZ_PER_BRANCH_CONFIG: '{"default":{"read":true,"write":true,"delete":true}}' ports: - '18081:8081' healthcheck: test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:8081/api/health'] interval: 5s timeout: 3s retries: 30 networks: - lsc-it lsc: build: context: . dockerfile: Dockerfile.lsc container_name: lsc-it-runner depends_on: ldap-source: condition: service_healthy ldap-rest: condition: service_healthy volumes: - ./lsc:/etc/lsc:ro - ./logs:/var/log/lsc environment: LDAP_REST_TOKEN: 'lsc-it-token-please-change-me-32chars' networks: - lsc-it command: ['tail', '-f', '/dev/null'] networks: lsc-it: driver: bridge linagora-ldap-rest-16e557e/lsc-plugin/test/integration/ldif/000077500000000000000000000000001522642357000240455ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/test/integration/ldif/source-seed.ldif000066400000000000000000000030711522642357000271240ustar00rootroot00000000000000dn: ou=users,dc=source,dc=example,dc=com objectClass: organizationalUnit ou: users dn: ou=groups,dc=source,dc=example,dc=com objectClass: organizationalUnit ou: groups dn: uid=alice,ou=users,dc=source,dc=example,dc=com objectClass: top objectClass: inetOrgPerson uid: alice cn: Alice Smith sn: Smith givenName: Alice mail: alice@source.example.com dn: uid=bob,ou=users,dc=source,dc=example,dc=com objectClass: top objectClass: inetOrgPerson uid: bob cn: Bob Jones sn: Jones givenName: Bob mail: bob@source.example.com dn: uid=carol,ou=users,dc=source,dc=example,dc=com objectClass: top objectClass: inetOrgPerson uid: carol cn: Carol White sn: White givenName: Carol mail: carol@source.example.com dn: uid=dave,ou=users,dc=source,dc=example,dc=com objectClass: top objectClass: inetOrgPerson uid: dave cn: Dave Brown sn: Brown givenName: Dave mail: dave@source.example.com dn: uid=eve,ou=users,dc=source,dc=example,dc=com objectClass: top objectClass: inetOrgPerson uid: eve cn: Eve Black sn: Black givenName: Eve mail: eve@source.example.com dn: cn=admins,ou=groups,dc=source,dc=example,dc=com objectClass: top objectClass: groupOfNames cn: admins description: Server administrators member: uid=alice,ou=users,dc=source,dc=example,dc=com member: uid=bob,ou=users,dc=source,dc=example,dc=com dn: cn=users,ou=groups,dc=source,dc=example,dc=com objectClass: top objectClass: groupOfNames cn: users description: Regular users member: uid=carol,ou=users,dc=source,dc=example,dc=com member: uid=dave,ou=users,dc=source,dc=example,dc=com member: uid=eve,ou=users,dc=source,dc=example,dc=com linagora-ldap-rest-16e557e/lsc-plugin/test/integration/lsc/000077500000000000000000000000001522642357000237105ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/test/integration/lsc/logback.xml000066400000000000000000000007141522642357000260360ustar00rootroot00000000000000 %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n linagora-ldap-rest-16e557e/lsc-plugin/test/integration/lsc/lsc.xml000066400000000000000000000124411522642357000252150ustar00rootroot00000000000000 src-ldap ldap://ldap-source:389/dc=source,dc=example,dc=com cn=admin,dc=source,dc=example,dc=com admin SIMPLE IGNORE NEVER VERSION_3 -1 com.sun.jndi.ldap.LdapCtxFactory false dst-ldap-rest-stub ldap://ldap-target:389/dc=target,dc=example,dc=com cn=admin,dc=target,dc=example,dc=com admin SIMPLE IGNORE NEVER VERSION_3 -1 com.sun.jndi.ldap.LdapCtxFactory false users-task org.lsc.beans.SimpleBean src-users ou=users,dc=source,dc=example,dc=com uid uid cn sn givenName mail dst-ldap-rest http://ldap-rest:8081 /api users ${LDAP_REST_TOKEN} 10000 3 "uid=" + srcBean.getDatasetFirstValueById("uid") + ",ou=users,dc=target,dc=example,dc=com" ; FORCE true true true true uid FORCE srcBean.getDatasetFirstValueById("uid") srcBean.getDatasetFirstValueById("uid") cn FORCE srcBean.getDatasetFirstValueById("cn") sn FORCE srcBean.getDatasetFirstValueById("sn") givenName FORCE srcBean.getDatasetFirstValueById("givenName") mail FORCE srcBean.getDatasetFirstValueById("mail") linagora-ldap-rest-16e557e/lsc-plugin/test/integration/tests/000077500000000000000000000000001522642357000242715ustar00rootroot00000000000000linagora-ldap-rest-16e557e/lsc-plugin/test/integration/tests/assertions.sh000066400000000000000000000042251522642357000270220ustar00rootroot00000000000000# Shared assertions sourced by run.sh. Increments $PASS / $FAIL via pass/fail. assert_users_created() { log " asserting 5 users exist in ldap-rest" code=$(rest_get "/api/v1/ldap/users") if [ "$code" = "200" ]; then n=$(jq '. | length' /tmp/rest-body 2>/dev/null || echo 0) if [ "$n" = "5" ]; then pass "ldap-rest GET /users returns 5 entries" else fail "expected 5 users in ldap-rest, got $n" fi else fail "ldap-rest GET /users returned $code" fi log " asserting alice exists with correct attrs in ldap-rest" code=$(rest_get "/api/v1/ldap/users/alice") if [ "$code" = "200" ] && jq -e '.uid == "alice" and .mail == "alice@source.example.com"' /tmp/rest-body >/dev/null; then pass "alice present with expected mail" else fail "alice not found or mail mismatch (code=$code)" fi log " asserting alice is reachable via direct ldapsearch on target" if $COMPOSE exec -T ldap-target ldapsearch -x -LLL -H ldap://localhost \ -D 'cn=admin,dc=target,dc=example,dc=com' -w admin \ -b 'ou=users,dc=target,dc=example,dc=com' \ '(uid=alice)' uid mail 2>/dev/null | grep -q '^uid: alice$'; then pass "alice reachable via ldapsearch on target (proves ldap-rest wrote LDAP, not just acked)" else fail "alice not found via direct ldapsearch — ldap-rest may have acked without writing" fi } assert_alice_mail_updated() { log " asserting alice's mail was updated" code=$(rest_get "/api/v1/ldap/users/alice") if [ "$code" = "200" ] && jq -e '.mail == "alice.updated@source.example.com"' /tmp/rest-body >/dev/null; then pass "alice mail updated to alice.updated@source.example.com" else actual=$(jq -r '.mail // ""' /tmp/rest-body 2>/dev/null || echo "") fail "alice mail not updated (got: $actual)" fi } assert_eve_deleted() { log " asserting eve was deleted" code=$(rest_get "/api/v1/ldap/users/eve") if [ "$code" = "404" ]; then pass "eve deleted (ldap-rest returns 404)" else fail "eve not deleted (ldap-rest returned $code)" fi } linagora-ldap-rest-16e557e/lsc-plugin/test/integration/tests/run.sh000077500000000000000000000064431522642357000254430ustar00rootroot00000000000000#!/usr/bin/env bash # Integration test orchestrator for the lsc-ldaprest-plugin. # Spins the docker-compose stack, runs LSC, asserts the expected state via # both the ldap-rest API and direct ldapsearch on the target, then tears # everything down (even on failure). set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$HERE/.." && pwd)" COMPOSE="docker compose -f $ROOT/docker-compose.yml" TOKEN="lsc-it-token-please-change-me-32chars" REST_HOST="http://localhost:18081" PASS=0 FAIL=0 log() { echo "[run.sh] $*" >&2; } pass() { echo " PASS: $*"; PASS=$((PASS+1)); } fail() { echo " FAIL: $*"; FAIL=$((FAIL+1)); } cleanup() { log "dumping container logs to $ROOT/logs/" mkdir -p "$ROOT/logs" for svc in ldap-source ldap-target ldap-rest lsc; do $COMPOSE logs --no-color "$svc" > "$ROOT/logs/$svc.log" 2>&1 || true done log "tearing down stack" $COMPOSE down -v --remove-orphans >/dev/null 2>&1 || true } trap cleanup EXIT log "building images and starting backing services" $COMPOSE up -d --build ldap-source ldap-target ldap-rest log "waiting for healthchecks (each service must report 'healthy', not just 'running')" deadline=$(( $(date +%s) + 180 )) for svc in ldap-source ldap-target ldap-rest; do while :; do cid=$($COMPOSE ps -q "$svc" 2>/dev/null) if [ -n "$cid" ]; then health=$(docker inspect -f '{{.State.Health.Status}}' "$cid" 2>/dev/null || echo unknown) if [ "$health" = "healthy" ]; then log " $svc: healthy" break fi fi if [ "$(date +%s)" -ge "$deadline" ]; then fail "$svc never reached healthy state (last status: $health)" exit 1 fi sleep 2 done done log "starting lsc container" $COMPOSE up -d --build lsc # Helper: GET via ldap-rest with bearer auth rest_get() { curl -s -o /tmp/rest-body -w '%{http_code}' \ -H "Authorization: Bearer $TOKEN" \ "$REST_HOST$1" } source "$HERE/assertions.sh" log "scenario 1: initial CREATE" $COMPOSE exec -T -e LDAP_REST_TOKEN=$TOKEN lsc lsc -f /etc/lsc -s users-task -t users-task >/tmp/lsc-1.log 2>&1 \ || { cat /tmp/lsc-1.log; fail "lsc run 1 failed"; } assert_users_created log "scenario 2: UPDATE — modify alice's mail in source, re-sync" $COMPOSE exec -T ldap-source bash -c "ldapmodify -x -D 'cn=admin,dc=source,dc=example,dc=com' -w admin </dev/null $COMPOSE exec -T -e LDAP_REST_TOKEN=$TOKEN lsc lsc -f /etc/lsc -s users-task -t users-task >/tmp/lsc-2.log 2>&1 \ || { cat /tmp/lsc-2.log; fail "lsc run 2 failed"; } assert_alice_mail_updated log "scenario 3: DELETE — remove eve from source, re-sync" $COMPOSE exec -T ldap-source ldapdelete -x -D 'cn=admin,dc=source,dc=example,dc=com' -w admin \ 'uid=eve,ou=users,dc=source,dc=example,dc=com' >/dev/null $COMPOSE exec -T -e LDAP_REST_TOKEN=$TOKEN lsc lsc -f /etc/lsc -s users-task -t users-task >/tmp/lsc-3.log 2>&1 \ || { cat /tmp/lsc-3.log; fail "lsc run 3 failed"; } assert_eve_deleted echo "" echo "=================================" echo "Results: $PASS passed, $FAIL failed" echo "=================================" if [ "$FAIL" -gt 0 ]; then exit 1 fi linagora-ldap-rest-16e557e/package-lock.json000066400000000000000000015551111522642357000207750ustar00rootroot00000000000000{ "name": "ldap-rest", "version": "0.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ldap-rest", "version": "0.4.6", "license": "AGPL-3.0", "dependencies": { "body-parser": "^2.2.0", "csv-parse": "^6.1.0", "express": "^5.1.0", "express-rate-limit": "^8.1.0", "ldapts": "^8.0.9", "lru-cache": "^11.2.2", "multer": "^2.0.2", "node-fetch": "^3.3.2", "p-limit": "^7.1.1", "winston": "^3.17.0" }, "bin": { "cleanup-external-users": "bin/cleanup-external-users.mjs", "ldap-rest": "bin/index.mjs", "sync-app-accounts": "bin/sync-app-accounts.mjs", "sync-james": "bin/sync-james.mjs" }, "devDependencies": { "@rollup/plugin-commonjs": "^28.0.6", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.4", "@types/chai": "^5.2.2", "@types/express": "^5.0.3", "@types/js-yaml": "^4.0.9", "@types/mocha": "^10.0.10", "@types/multer": "^2.0.0", "@types/node": "^24.5.1", "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^8.44.0", "@typescript-eslint/parser": "^8.44.0", "chai": "^6.0.1", "eslint": "^9.35.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^5.5.4", "fast-glob": "^3.3.3", "js-yaml": "^4.1.1", "mocha": "^11.7.2", "nock": "^14.0.10", "prettier": "^3.6.2", "rimraf": "^6.0.1", "rollup": "^4.50.2", "rollup-plugin-postcss": "^4.0.2", "sort-package-json": "^3.4.0", "supertest": "^7.1.4", "tslib": "^2.8.1", "tsx": "^4.20.5", "typescript": "^5.9.2" }, "optionalDependencies": { "@linagora/rabbitmq-client": "^0.1.2", "express-openid-connect": "^2.19.2", "lemonldap-ng-handler": "^0.7.3", "re2": "^1.22.1" } }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", "optional": true, "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "optional": true, "dependencies": { "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "optional": true, "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "optional": true, "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@aws-crypto/sha256-js": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", "optional": true, "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" }, "engines": { "node": ">=16.0.0" } }, "node_modules/@aws-crypto/supports-web-crypto": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", "optional": true, "dependencies": { "tslib": "^2.6.2" } }, "node_modules/@aws-crypto/util": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", "optional": true, "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "optional": true, "dependencies": { "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "optional": true, "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "optional": true, "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@aws-sdk/client-cognito-identity": { "version": "3.981.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.981.0.tgz", "integrity": "sha512-Epc/dSH5VlAHBYxLGNZm+ZZNF2vHoNJdrVa1NJfYylaLVGgQpscoT8QN7ijqQUl7b888JAAGY5tAFSlvPqeoLA==", "optional": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.5", "@aws-sdk/credential-provider-node": "^3.972.4", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.5", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.981.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.3", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.12", "@smithy/middleware-retry": "^4.4.29", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.28", "@smithy/util-defaults-mode-node": "^4.2.31", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/core": { "version": "3.973.15", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.15.tgz", "integrity": "sha512-AlC0oQ1/mdJ8vCIqu524j5RB7M8i8E24bbkZmya1CuiQxkY7SdIZAyw7NDNMGaNINQFq/8oGRMX0HeOfCVsl/A==", "optional": true, "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws-sdk/xml-builder": "^3.972.8", "@smithy/core": "^3.23.6", "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-cognito-identity": { "version": "3.972.6", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.6.tgz", "integrity": "sha512-RJqEZYFoXkBTVCwSJuYFd311qc/Q/cBJ8BH08+ggX/rUTWw47TUEyZlxzyTlKfP7DoXG4Khu/TX+pzU6godEGQ==", "optional": true, "dependencies": { "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-cognito-identity/node_modules/@aws-sdk/nested-clients": { "version": "3.996.3", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.3.tgz", "integrity": "sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw==", "optional": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.15", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.15", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.0", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.6", "@smithy/fetch-http-handler": "^5.3.11", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.20", "@smithy/middleware-retry": "^4.4.37", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.12", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.36", "@smithy/util-defaults-mode-node": "^4.2.39", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-cognito-identity/node_modules/@aws-sdk/util-endpoints": { "version": "3.996.3", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", "optional": true, "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-env": { "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.13.tgz", "integrity": "sha512-6ljXKIQ22WFKyIs1jbORIkGanySBHaPPTOI4OxACP5WXgbcR0nDYfqNJfXEGwCK7IzHdNbCSFsNKKs0qCexR8Q==", "optional": true, "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-http": { "version": "3.972.15", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.15.tgz", "integrity": "sha512-dJuSTreu/T8f24SHDNTjd7eQ4rabr0TzPh2UTCwYexQtzG3nTDKm1e5eIdhiroTMDkPEJeY+WPkA6F9wod/20A==", "optional": true, "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/types": "^3.973.4", "@smithy/fetch-http-handler": "^5.3.11", "@smithy/node-http-handler": "^4.4.12", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.15", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-ini": { "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.13.tgz", "integrity": "sha512-JKSoGb7XeabZLBJptpqoZIFbROUIS65NuQnEHGOpuT9GuuZwag2qciKANiDLFiYk4u8nSrJC9JIOnWKVvPVjeA==", "optional": true, "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/credential-provider-env": "^3.972.13", "@aws-sdk/credential-provider-http": "^3.972.15", "@aws-sdk/credential-provider-login": "^3.972.13", "@aws-sdk/credential-provider-process": "^3.972.13", "@aws-sdk/credential-provider-sso": "^3.972.13", "@aws-sdk/credential-provider-web-identity": "^3.972.13", "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-ini/node_modules/@aws-sdk/nested-clients": { "version": "3.996.3", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.3.tgz", "integrity": "sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw==", "optional": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.15", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.15", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.0", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.6", "@smithy/fetch-http-handler": "^5.3.11", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.20", "@smithy/middleware-retry": "^4.4.37", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.12", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.36", "@smithy/util-defaults-mode-node": "^4.2.39", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-ini/node_modules/@aws-sdk/util-endpoints": { "version": "3.996.3", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", "optional": true, "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-login": { "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.13.tgz", "integrity": "sha512-RtYcrxdnJHKY8MFQGLltCURcjuMjnaQpAxPE6+/QEdDHHItMKZgabRe/KScX737F9vJMQsmJy9EmMOkCnoC1JQ==", "optional": true, "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/nested-clients": { "version": "3.996.3", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.3.tgz", "integrity": "sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw==", "optional": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.15", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.15", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.0", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.6", "@smithy/fetch-http-handler": "^5.3.11", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.20", "@smithy/middleware-retry": "^4.4.37", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.12", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.36", "@smithy/util-defaults-mode-node": "^4.2.39", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-login/node_modules/@aws-sdk/util-endpoints": { "version": "3.996.3", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", "optional": true, "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-node": { "version": "3.972.14", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.14.tgz", "integrity": "sha512-WqoC2aliIjQM/L3oFf6j+op/enT2i9Cc4UTxxMEKrJNECkq4/PlKE5BOjSYFcq6G9mz65EFbXJh7zOU4CvjSKQ==", "optional": true, "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.13", "@aws-sdk/credential-provider-http": "^3.972.15", "@aws-sdk/credential-provider-ini": "^3.972.13", "@aws-sdk/credential-provider-process": "^3.972.13", "@aws-sdk/credential-provider-sso": "^3.972.13", "@aws-sdk/credential-provider-web-identity": "^3.972.13", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-process": { "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.13.tgz", "integrity": "sha512-rsRG0LQA4VR+jnDyuqtXi2CePYSmfm5GNL9KxiW8DSe25YwJSr06W8TdUfONAC+rjsTI+aIH2rBGG5FjMeANrw==", "optional": true, "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-sso": { "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.13.tgz", "integrity": "sha512-fr0UU1wx8kNHDhTQBXioc/YviSW8iXuAxHvnH7eQUtn8F8o/FU3uu6EUMvAQgyvn7Ne5QFnC0Cj0BFlwCk+RFw==", "optional": true, "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/token-providers": "3.999.0", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/nested-clients": { "version": "3.996.3", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.3.tgz", "integrity": "sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw==", "optional": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.15", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.15", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.0", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.6", "@smithy/fetch-http-handler": "^5.3.11", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.20", "@smithy/middleware-retry": "^4.4.37", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.12", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.36", "@smithy/util-defaults-mode-node": "^4.2.39", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/util-endpoints": { "version": "3.996.3", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", "optional": true, "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-web-identity": { "version": "3.972.13", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.13.tgz", "integrity": "sha512-a6iFMh1pgUH0TdcouBppLJUfPM7Yd3R9S1xFodPtCRoLqCz2RQFA3qjA8x4112PVYXEd4/pHX2eihapq39w0rA==", "optional": true, "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-web-identity/node_modules/@aws-sdk/nested-clients": { "version": "3.996.3", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.3.tgz", "integrity": "sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw==", "optional": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.15", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.15", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.0", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.6", "@smithy/fetch-http-handler": "^5.3.11", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.20", "@smithy/middleware-retry": "^4.4.37", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.12", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.36", "@smithy/util-defaults-mode-node": "^4.2.39", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-web-identity/node_modules/@aws-sdk/util-endpoints": { "version": "3.996.3", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", "optional": true, "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-providers": { "version": "3.981.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.981.0.tgz", "integrity": "sha512-ULmXLUvZqQDqH4SgTcFXHHUf9RSa/4H+BC3/UDpiq2t2515MUPqSw6cgEpCax/6v0zY5CVWe8GBGj/Rx/saGPA==", "optional": true, "dependencies": { "@aws-sdk/client-cognito-identity": "3.981.0", "@aws-sdk/core": "^3.973.5", "@aws-sdk/credential-provider-cognito-identity": "^3.972.3", "@aws-sdk/credential-provider-env": "^3.972.3", "@aws-sdk/credential-provider-http": "^3.972.5", "@aws-sdk/credential-provider-ini": "^3.972.3", "@aws-sdk/credential-provider-login": "^3.972.3", "@aws-sdk/credential-provider-node": "^3.972.4", "@aws-sdk/credential-provider-process": "^3.972.3", "@aws-sdk/credential-provider-sso": "^3.972.3", "@aws-sdk/credential-provider-web-identity": "^3.972.3", "@aws-sdk/nested-clients": "3.981.0", "@aws-sdk/types": "^3.973.1", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/middleware-host-header": { "version": "3.972.6", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.6.tgz", "integrity": "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w==", "optional": true, "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/middleware-logger": { "version": "3.972.6", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.6.tgz", "integrity": "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw==", "optional": true, "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/middleware-recursion-detection": { "version": "3.972.6", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.6.tgz", "integrity": "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw==", "optional": true, "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/middleware-user-agent": { "version": "3.972.15", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.15.tgz", "integrity": "sha512-ABlFVcIMmuRAwBT+8q5abAxOr7WmaINirDJBnqGY5b5jSDo00UMlg/G4a0xoAgwm6oAECeJcwkvDlxDwKf58fQ==", "optional": true, "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@smithy/core": "^3.23.6", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/util-endpoints": { "version": "3.996.3", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", "optional": true, "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/nested-clients": { "version": "3.981.0", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.981.0.tgz", "integrity": "sha512-U8Nv/x0+9YleQ0yXHy0bVxjROSXXLzFzInRs/Q/Un+7FShHnS72clIuDZphK0afesszyDFS7YW4QFnm1sFIrCg==", "optional": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.5", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.981.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.3", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.22.0", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.12", "@smithy/middleware-retry": "^4.4.29", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.8", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.28", "@smithy/util-defaults-mode-node": "^4.2.31", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/region-config-resolver": { "version": "3.972.6", "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.6.tgz", "integrity": "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw==", "optional": true, "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/config-resolver": "^4.4.9", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/token-providers": { "version": "3.999.0", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.999.0.tgz", "integrity": "sha512-cx0hHUlgXULfykx4rdu/ciNAJaa3AL5xz3rieCz7NKJ68MJwlj3664Y8WR5MGgxfyYJBdamnkjNSx5Kekuc0cg==", "optional": true, "dependencies": { "@aws-sdk/core": "^3.973.15", "@aws-sdk/nested-clients": "^3.996.3", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/token-providers/node_modules/@aws-sdk/nested-clients": { "version": "3.996.3", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.3.tgz", "integrity": "sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw==", "optional": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.15", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.15", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.0", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.6", "@smithy/fetch-http-handler": "^5.3.11", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.20", "@smithy/middleware-retry": "^4.4.37", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.12", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.36", "@smithy/util-defaults-mode-node": "^4.2.39", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/token-providers/node_modules/@aws-sdk/util-endpoints": { "version": "3.996.3", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", "optional": true, "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/types": { "version": "3.973.4", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.4.tgz", "integrity": "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==", "optional": true, "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/util-endpoints": { "version": "3.981.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.981.0.tgz", "integrity": "sha512-a8nXh/H3/4j+sxhZk+N3acSDlgwTVSZbX9i55dx41gI1H+geuonuRG+Shv3GZsCb46vzc08RK2qC78ypO8uRlg==", "optional": true, "dependencies": { "@aws-sdk/types": "^3.973.1", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.965.4", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz", "integrity": "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==", "optional": true, "dependencies": { "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws-sdk/util-user-agent-browser": { "version": "3.972.6", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.6.tgz", "integrity": "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA==", "optional": true, "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { "version": "3.973.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.0.tgz", "integrity": "sha512-A9J2G4Nf236e9GpaC1JnA8wRn6u6GjnOXiTwBLA6NUJhlBTIGfrTy+K1IazmF8y+4OFdW3O5TZlhyspJMqiqjA==", "optional": true, "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.15", "@aws-sdk/types": "^3.973.4", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "peerDependenciesMeta": { "aws-crt": { "optional": true } } }, "node_modules/@aws-sdk/xml-builder": { "version": "3.972.19", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.19.tgz", "integrity": "sha512-Cw8IOMdBUEIl8ZlhRC3Dc/E64D5B5/8JhV6vhPLiPfJwcRC84S6F8aBOIi/N4vR9ZyA4I5Cc0Ateb/9EHaJXeQ==", "license": "Apache-2.0", "optional": true, "dependencies": { "@smithy/types": "^4.14.1", "fast-xml-parser": "5.7.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@aws/lambda-invoke-store": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", "optional": true, "engines": { "node": ">=18.0.0" } }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", "engines": { "node": ">=0.1.90" } }, "node_modules/@dabh/diagnostics": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "aix" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/openharmony-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "sunos" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/config-array": { "version": "0.21.1", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "dependencies": { "@eslint/core": "^0.17.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/eslintrc": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/@eslint/eslintrc/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "engines": { "node": ">= 4" } }, "node_modules/@eslint/js": { "version": "9.39.2", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", "optional": true }, "node_modules/@hapi/topo": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", "optional": true, "dependencies": { "@hapi/hoek": "^9.0.0" } }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { "version": "0.16.7", "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "engines": { "node": ">=12.22" }, "funding": { "type": "github", "url": "https://github.com/sponsors/nzakas" } }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "engines": { "node": ">=18.18" }, "funding": { "type": "github", "url": "https://github.com/sponsors/nzakas" } }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "optional": true, "dependencies": { "minipass": "^7.0.4" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/source-map": { "version": "0.3.11", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@lemonldap-ng/conf": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/@lemonldap-ng/conf/-/conf-0.7.3.tgz", "integrity": "sha512-pTWmr/V9PkB8VmcNb1NCqtNA3EI6NW29BIxyORE/dD3xObC8pBKx/74ojazomocN532ONgqCaDp94R8eSOuZCA==", "optional": true, "dependencies": { "@lemonldap-ng/conf-file": "^0.7.0", "@lemonldap-ng/crypto": "^0.7.0", "ini": "^3.0.0" }, "optionalDependencies": { "@lemonldap-ng/conf-cdbi": "^0.7.3", "@lemonldap-ng/conf-ldap": "^0.7.1", "@lemonldap-ng/conf-mongodb": "^0.7.0", "@lemonldap-ng/conf-rdbi": "^0.7.3", "@lemonldap-ng/conf-rest": "^0.7.0", "@lemonldap-ng/conf-yaml": "^0.7.0" } }, "node_modules/@lemonldap-ng/conf-cdbi": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/@lemonldap-ng/conf-cdbi/-/conf-cdbi-0.7.3.tgz", "integrity": "sha512-CNans2ZpxrTmFr4DnMbpJpaSo6YLIlRsfWdm6lmsdtOp0c33H7Jr3j5LHHsCmbnHvDyNDpHvDdF4fBagWaYX3g==", "optional": true, "dependencies": { "@lemonldap-ng/conf-dbi": "^0.7.3" } }, "node_modules/@lemonldap-ng/conf-dbi": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/@lemonldap-ng/conf-dbi/-/conf-dbi-0.7.3.tgz", "integrity": "sha512-dtSSU7prfEMVnfAhgbMZYUjfdvM+x70JcoyeT3u+2t0+zmyiTiv2u0bYDN1FWgGBewaZOE989GkOZpW+9DCQMA==", "optional": true, "dependencies": { "perl-dbi": "^0.7.0" } }, "node_modules/@lemonldap-ng/conf-file": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@lemonldap-ng/conf-file/-/conf-file-0.7.0.tgz", "integrity": "sha512-9Q0zx6P9dCHUqWWp4LwtCD6MaG2W2rnxgod4TYGbLuxZ1lmEci8aY73Ul2ZNsr3VTPU/Bb62jHrPAg3Mkusg/Q==", "optional": true }, "node_modules/@lemonldap-ng/conf-ldap": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/@lemonldap-ng/conf-ldap/-/conf-ldap-0.7.1.tgz", "integrity": "sha512-QdtJ+e1uLlB/F82bUNMCRciDL4T/kBg9OV34+P7Rtzr1H+cVaPMQPdwYnHuzTZKHMm85CY/U499zGlN+tfnJhw==", "optional": true, "dependencies": { "ldapts": "^8.0.9" } }, "node_modules/@lemonldap-ng/conf-mongodb": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@lemonldap-ng/conf-mongodb/-/conf-mongodb-0.7.0.tgz", "integrity": "sha512-phqEeKRUPmClZ9W8wE4hnBnuv1GwXXXAZQMkcyCfKSF4YUzpIaPHi4chfM+aEoEHaCTljXSejvS5P8Ws93F2DA==", "optional": true, "dependencies": { "mongodb": "^4.0.7" } }, "node_modules/@lemonldap-ng/conf-rdbi": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/@lemonldap-ng/conf-rdbi/-/conf-rdbi-0.7.3.tgz", "integrity": "sha512-u+Xdti7ySTRALg7yCqs0QyGAhcs/m2adgkg+sLbkQLgF2F63CGdA52kjdO1v4fjFCIH2um7GFgp9JQ33D8ewHA==", "optional": true, "dependencies": { "@lemonldap-ng/conf-dbi": "^0.7.3" } }, "node_modules/@lemonldap-ng/conf-rest": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/@lemonldap-ng/conf-rest/-/conf-rest-0.7.1.tgz", "integrity": "sha512-3uf/2m8TZYyo7SYNWV6grXiaKVvAloPlPV9DdORtsMfpTBUEpDgb3LfCZL7UUXegXk6vOR6+wNYIGxtSiU0fGQ==", "optional": true, "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.5", "formdata-polyfill": "^4.0.10", "node-fetch": "^3.2.6" } }, "node_modules/@lemonldap-ng/conf-yaml": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/@lemonldap-ng/conf-yaml/-/conf-yaml-0.7.3.tgz", "integrity": "sha512-etugP1c+U12e7wl1N+yk6yFnZk15Ju8VTuTu+Con5dm8uQ9aU25pB3P++8Af76RAXtWccg0Veom3vfzet3KsqQ==", "optional": true, "dependencies": { "@lemonldap-ng/conf-file": "^0.7.0", "js-yaml": "^4.1.0" } }, "node_modules/@lemonldap-ng/crypto": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@lemonldap-ng/crypto/-/crypto-0.7.0.tgz", "integrity": "sha512-6JxSjvXq8gpBz3AH4WEg9HtKL+JpRcAyUNreXHvD8yYnNjhzoC+smWJYlNSmwPhdyvKf0c4VFP8k5098e7PIBA==", "optional": true, "dependencies": { "aes-js": "^3.1.2", "random-bytes": "^1.0.0", "sha.js": "^2.4.11" } }, "node_modules/@lemonldap-ng/logger": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@lemonldap-ng/logger/-/logger-0.7.0.tgz", "integrity": "sha512-fdyUReDiunEX4cIq9u+Bttw3kgkWmJEGAWjPsgCPbkNVM9WQNTOSltamMRqdkSI2wxyKp7p/hcsdaoRJHwpg4w==", "optional": true }, "node_modules/@lemonldap-ng/safelib": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/@lemonldap-ng/safelib/-/safelib-0.7.1.tgz", "integrity": "sha512-IeAKD8un3BaUhkPqlsr51SQz8WlpFJVOLpVpK+TDA9YQuyURqBqIxZjQ72PQ7dUV5tvDfmujxJsyvFMPPKu50Q==", "optional": true, "dependencies": { "iconv-lite": "^0.6.3", "ipaddr.js": "^2.0.1" } }, "node_modules/@lemonldap-ng/safelib/node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" } }, "node_modules/@lemonldap-ng/session": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/@lemonldap-ng/session/-/session-0.7.3.tgz", "integrity": "sha512-Da+RWhCEHLPd8J+NG3jTypwPffpI2HAJe1M6gmzjDpdH2ribybDIv/uOf4PP+XyyJ5XYERONnDX0bhrdejiXgA==", "optional": true, "dependencies": { "@lemonldap-ng/crypto": "^0.7.0", "ini": "^3.0.0", "limited-cache": "^2.0.0", "node-persist": "^3.1.0" }, "optionalDependencies": { "@lemonldap-ng/session-file": "^0.7.0", "@lemonldap-ng/session-ldap": "^0.7.1", "@lemonldap-ng/session-mysql": "^0.7.3", "@lemonldap-ng/session-postgres": "^0.7.3", "@lemonldap-ng/session-redis": "^0.7.0", "@lemonldap-ng/session-rest": "^0.7.0" } }, "node_modules/@lemonldap-ng/session-dbi": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/@lemonldap-ng/session-dbi/-/session-dbi-0.7.3.tgz", "integrity": "sha512-51CBT8XJo9O/txE4noLByCXrnAcI2OCqEDv2mU12syhXr9IP+19RLmm1xMtqKco6gu8BZGCQgYhYzeUXLksOWA==", "optional": true, "dependencies": { "perl-dbi": "^0.7.0" } }, "node_modules/@lemonldap-ng/session-file": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@lemonldap-ng/session-file/-/session-file-0.7.0.tgz", "integrity": "sha512-m52uBwHDb4kSK73GyX+JoB7fRl5lLLDH4z9AHpTqeYrFh+/18AJ8iZ+ag7OMiOcyab3qQ59yjGVs3DvLLtoyGw==", "optional": true }, "node_modules/@lemonldap-ng/session-ldap": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/@lemonldap-ng/session-ldap/-/session-ldap-0.7.1.tgz", "integrity": "sha512-UQ3dYQD4ZsY7y3LBJHLe8EnqIj3gd0fw+0ipIUTRK2HPDWpjOXq4teY0qx572qvy+3qi9mCehgSPpAdu8Q4lLQ==", "optional": true, "dependencies": { "ldapts": "^8.0.9" } }, "node_modules/@lemonldap-ng/session-mysql": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/@lemonldap-ng/session-mysql/-/session-mysql-0.7.3.tgz", "integrity": "sha512-W+Y/S67cfGnzCIiGj1D2C7YPOGr55fpUAUDlar1FxjmGhjqtYy6QjChPi4Ez8t0w687EKzvl71OYl7jDbJ9MUA==", "optional": true, "dependencies": { "@lemonldap-ng/session-dbi": "^0.7.3", "mysql": "^2.18.1" } }, "node_modules/@lemonldap-ng/session-postgres": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/@lemonldap-ng/session-postgres/-/session-postgres-0.7.3.tgz", "integrity": "sha512-oFf3pxr+M0cYyk/OrVAz1XnpLDI9g002ZFTuhQCnU+ROokdiEEEsdUTsK/X6aEbPMxinYyOpMaPkjfbZfj/ztw==", "optional": true, "dependencies": { "@lemonldap-ng/session-dbi": "^0.7.3", "pg": "^8.7.3" } }, "node_modules/@lemonldap-ng/session-redis": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@lemonldap-ng/session-redis/-/session-redis-0.7.0.tgz", "integrity": "sha512-7LPamOG1dMzD7XRIo/XuDiOLeMNBUHQCZACSy6oZ18aOJogpW+Vtx2QX8QsZNTvtnKVfawqdkYgkKPPktnVS7Q==", "optional": true, "dependencies": { "redis": "^4.1.0" } }, "node_modules/@lemonldap-ng/session-rest": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/@lemonldap-ng/session-rest/-/session-rest-0.7.1.tgz", "integrity": "sha512-ps8pzd4tqI3nCNxe1yoREBZ4Xij5f705G0qmRGLTTEwQYmcJaxPzqqmqAR+s/UlG0aZSVfIH0XyDIz0PNH/5pQ==", "optional": true, "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.5", "formdata-polyfill": "^4.0.10", "node-fetch": "^3.2.6" } }, "node_modules/@linagora/rabbitmq-client": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@linagora/rabbitmq-client/-/rabbitmq-client-0.1.2.tgz", "integrity": "sha512-ZjL2PP3fbSz5tKhgBRl4G9arQTJj+qqeZFFQMMM+7VWceZ1+GXHqqvBACkwle0YEjOcKbPBGog2cQe21ovKHxw==", "license": "MIT", "optional": true, "dependencies": { "amqplib": "^0.10.7", "pino": "^9.6.0" }, "engines": { "node": ">=18" } }, "node_modules/@mongodb-js/saslprep": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.4.tgz", "integrity": "sha512-p7X/ytJDIdwUfFL/CLOhKgdfJe1Fa8uw9seJYvdOmnP9JBWGWHW69HkOixXS6Wy9yvGf1MbhcS6lVmrhy4jm2g==", "optional": true, "dependencies": { "sparse-bitfield": "^3.0.3" } }, "node_modules/@mswjs/interceptors": { "version": "0.39.8", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.8.tgz", "integrity": "sha512-2+BzZbjRO7Ct61k8fMNHEtoKjeWI9pIlHFTqBwZ5icHpqszIgEZbjb1MW5Z0+bITTCTl3gk4PDBxs9tA/csXvA==", "dev": true, "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" }, "engines": { "node": ">=18" } }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "dev": true, "engines": { "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/nodable" } ], "license": "MIT", "optional": true }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" }, "engines": { "node": ">= 8" } }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "engines": { "node": ">= 8" } }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" }, "engines": { "node": ">= 8" } }, "node_modules/@npmcli/agent": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", "optional": true, "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@npmcli/fs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", "optional": true, "dependencies": { "semver": "^7.3.5" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@open-draft/deferred-promise": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", "dev": true }, "node_modules/@open-draft/logger": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", "dev": true, "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "node_modules/@open-draft/until": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "dev": true }, "node_modules/@panva/asn1.js": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==", "optional": true, "engines": { "node": ">=10.13.0" } }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", "dev": true, "dependencies": { "@noble/hashes": "^1.1.5" } }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT", "optional": true }, "node_modules/@pkgr/core": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", "dev": true, "engines": { "node": "^12.20.0 || ^14.18.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/pkgr" } }, "node_modules/@redis/bloom": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", "optional": true, "peerDependencies": { "@redis/client": "^1.0.0" } }, "node_modules/@redis/client": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "optional": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", "yallist": "4.0.0" }, "engines": { "node": ">=14" } }, "node_modules/@redis/graph": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", "optional": true, "peerDependencies": { "@redis/client": "^1.0.0" } }, "node_modules/@redis/json": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", "optional": true, "peerDependencies": { "@redis/client": "^1.0.0" } }, "node_modules/@redis/search": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", "optional": true, "peerDependencies": { "@redis/client": "^1.0.0" } }, "node_modules/@redis/time-series": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", "optional": true, "peerDependencies": { "@redis/client": "^1.0.0" } }, "node_modules/@rollup/plugin-commonjs": { "version": "28.0.9", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz", "integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==", "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "engines": { "node": ">=16.0.0 || 14 >= 14.17" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "peerDependenciesMeta": { "rollup": { "optional": true } } }, "node_modules/@rollup/plugin-json": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", "dev": true, "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "peerDependenciesMeta": { "rollup": { "optional": true } } }, "node_modules/@rollup/plugin-node-resolve": { "version": "16.0.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "peerDependenciesMeta": { "rollup": { "optional": true } } }, "node_modules/@rollup/plugin-terser": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", "dev": true, "dependencies": { "serialize-javascript": "^6.0.1", "smob": "^1.0.0", "terser": "^5.17.4" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { "rollup": "^2.0.0||^3.0.0||^4.0.0" }, "peerDependenciesMeta": { "rollup": { "optional": true } } }, "node_modules/@rollup/plugin-typescript": { "version": "12.3.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz", "integrity": "sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==", "dev": true, "dependencies": { "@rollup/pluginutils": "^5.1.0", "resolve": "^1.22.1" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { "rollup": "^2.14.0||^3.0.0||^4.0.0", "tslib": "*", "typescript": ">=3.7.0" }, "peerDependenciesMeta": { "rollup": { "optional": true }, "tslib": { "optional": true } } }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "dev": true, "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "peerDependenciesMeta": { "rollup": { "optional": true } } }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], "dev": true, "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "openbsd" ] }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "openharmony" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], "dev": true, "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "win32" ] }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", "optional": true, "dependencies": { "@hapi/hoek": "^9.0.0" } }, "node_modules/@sideway/formula": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", "optional": true }, "node_modules/@sideway/pinpoint": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", "optional": true }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", "optional": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sindresorhus/is?sponsor=1" } }, "node_modules/@smithy/abort-controller": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.10.tgz", "integrity": "sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==", "optional": true, "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/config-resolver": { "version": "4.4.9", "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.9.tgz", "integrity": "sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==", "optional": true, "dependencies": { "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.1", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/core": { "version": "3.23.6", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.6.tgz", "integrity": "sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg==", "optional": true, "dependencies": { "@smithy/middleware-serde": "^4.2.11", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-stream": "^4.5.15", "@smithy/util-utf8": "^4.2.1", "@smithy/uuid": "^1.1.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/credential-provider-imds": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.10.tgz", "integrity": "sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ==", "optional": true, "dependencies": { "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/fetch-http-handler": { "version": "5.3.11", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.11.tgz", "integrity": "sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g==", "optional": true, "dependencies": { "@smithy/protocol-http": "^5.3.10", "@smithy/querystring-builder": "^4.2.10", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/hash-node": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.10.tgz", "integrity": "sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==", "optional": true, "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-buffer-from": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/invalid-dependency": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.10.tgz", "integrity": "sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==", "optional": true, "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/is-array-buffer": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.1.tgz", "integrity": "sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==", "optional": true, "dependencies": { "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/middleware-content-length": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.10.tgz", "integrity": "sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==", "optional": true, "dependencies": { "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/middleware-endpoint": { "version": "4.4.20", "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.20.tgz", "integrity": "sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw==", "optional": true, "dependencies": { "@smithy/core": "^3.23.6", "@smithy/middleware-serde": "^4.2.11", "@smithy/node-config-provider": "^4.3.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-middleware": "^4.2.10", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/middleware-retry": { "version": "4.4.37", "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.37.tgz", "integrity": "sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA==", "optional": true, "dependencies": { "@smithy/node-config-provider": "^4.3.10", "@smithy/protocol-http": "^5.3.10", "@smithy/service-error-classification": "^4.2.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/uuid": "^1.1.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/middleware-serde": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.11.tgz", "integrity": "sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==", "optional": true, "dependencies": { "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/middleware-stack": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.10.tgz", "integrity": "sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==", "optional": true, "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/node-config-provider": { "version": "4.3.10", "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.10.tgz", "integrity": "sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==", "optional": true, "dependencies": { "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/node-http-handler": { "version": "4.4.12", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.12.tgz", "integrity": "sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w==", "optional": true, "dependencies": { "@smithy/abort-controller": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/querystring-builder": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/property-provider": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.10.tgz", "integrity": "sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==", "optional": true, "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/protocol-http": { "version": "5.3.10", "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.10.tgz", "integrity": "sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==", "optional": true, "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/querystring-builder": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.10.tgz", "integrity": "sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==", "optional": true, "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-uri-escape": "^4.2.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/querystring-parser": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.10.tgz", "integrity": "sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==", "optional": true, "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/service-error-classification": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.10.tgz", "integrity": "sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==", "optional": true, "dependencies": { "@smithy/types": "^4.13.0" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.5.tgz", "integrity": "sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==", "optional": true, "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/signature-v4": { "version": "5.3.10", "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.10.tgz", "integrity": "sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==", "optional": true, "dependencies": { "@smithy/is-array-buffer": "^4.2.1", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-hex-encoding": "^4.2.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-uri-escape": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/smithy-client": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.0.tgz", "integrity": "sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ==", "optional": true, "dependencies": { "@smithy/core": "^3.23.6", "@smithy/middleware-endpoint": "^4.4.20", "@smithy/middleware-stack": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.15", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/types": { "version": "4.14.1", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", "license": "Apache-2.0", "optional": true, "dependencies": { "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/url-parser": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.10.tgz", "integrity": "sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==", "optional": true, "dependencies": { "@smithy/querystring-parser": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/util-base64": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.1.tgz", "integrity": "sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==", "optional": true, "dependencies": { "@smithy/util-buffer-from": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/util-body-length-browser": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.1.tgz", "integrity": "sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==", "optional": true, "dependencies": { "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/util-body-length-node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.2.tgz", "integrity": "sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==", "optional": true, "dependencies": { "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/util-buffer-from": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.1.tgz", "integrity": "sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==", "optional": true, "dependencies": { "@smithy/is-array-buffer": "^4.2.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/util-config-provider": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.1.tgz", "integrity": "sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==", "optional": true, "dependencies": { "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/util-defaults-mode-browser": { "version": "4.3.36", "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.36.tgz", "integrity": "sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew==", "optional": true, "dependencies": { "@smithy/property-provider": "^4.2.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/util-defaults-mode-node": { "version": "4.2.39", "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.39.tgz", "integrity": "sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg==", "optional": true, "dependencies": { "@smithy/config-resolver": "^4.4.9", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", "@smithy/smithy-client": "^4.12.0", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/util-endpoints": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.1.tgz", "integrity": "sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==", "optional": true, "dependencies": { "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/util-hex-encoding": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.1.tgz", "integrity": "sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==", "optional": true, "dependencies": { "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/util-middleware": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.10.tgz", "integrity": "sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==", "optional": true, "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/util-retry": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.10.tgz", "integrity": "sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==", "optional": true, "dependencies": { "@smithy/service-error-classification": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/util-stream": { "version": "4.5.15", "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.15.tgz", "integrity": "sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==", "optional": true, "dependencies": { "@smithy/fetch-http-handler": "^5.3.11", "@smithy/node-http-handler": "^4.4.12", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-buffer-from": "^4.2.1", "@smithy/util-hex-encoding": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/util-uri-escape": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.1.tgz", "integrity": "sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==", "optional": true, "dependencies": { "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/util-utf8": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.1.tgz", "integrity": "sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==", "optional": true, "dependencies": { "@smithy/util-buffer-from": "^4.2.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/uuid": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.1.tgz", "integrity": "sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==", "optional": true, "dependencies": { "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", "optional": true, "dependencies": { "defer-to-connect": "^2.0.0" }, "engines": { "node": ">=10" } }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "dev": true, "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", "optional": true, "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "dev": true, "dependencies": { "@types/node": "*" } }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", "dev": true }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "node_modules/@types/express-serve-static-core": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", "dev": true, "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", "optional": true }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "dev": true }, "node_modules/@types/js-yaml": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", "optional": true, "dependencies": { "@types/node": "*" } }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", "dev": true }, "node_modules/@types/mocha": { "version": "10.0.10", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", "dev": true }, "node_modules/@types/multer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", "dev": true, "dependencies": { "@types/express": "*" } }, "node_modules/@types/node": { "version": "24.10.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "devOptional": true, "dependencies": { "undici-types": "~7.16.0" } }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "dev": true }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "dev": true }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", "optional": true, "dependencies": { "@types/node": "*" } }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "dev": true, "dependencies": { "@types/node": "*" } }, "node_modules/@types/serve-static": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "dev": true, "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "node_modules/@types/superagent": { "version": "8.1.9", "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", "dev": true, "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "node_modules/@types/supertest": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", "dev": true, "dependencies": { "@types/methods": "^1.1.4", "@types/superagent": "^8.1.0" } }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", "optional": true }, "node_modules/@types/whatwg-url": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", "optional": true, "dependencies": { "@types/node": "*", "@types/webidl-conversions": "*" } }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/type-utils": "8.56.0", "@typescript-eslint/utils": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", "dev": true, "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.0", "@typescript-eslint/types": "^8.56.0", "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", "dev": true, "dependencies": { "@typescript-eslint/types": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, "node_modules/@typescript-eslint/tsconfig-utils": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", "dev": true, "dependencies": { "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0", "@typescript-eslint/utils": "8.56.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, "node_modules/@typescript-eslint/typescript-estree": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", "dev": true, "dependencies": { "@typescript-eslint/project-service": "8.56.0", "@typescript-eslint/tsconfig-utils": "8.56.0", "@typescript-eslint/types": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/utils": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", "@typescript-eslint/typescript-estree": "8.56.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", "dev": true, "dependencies": { "@typescript-eslint/types": "8.56.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", "dev": true, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/abbrev": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", "optional": true, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" }, "engines": { "node": ">= 0.6" } }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "bin": { "acorn": "bin/acorn" }, "engines": { "node": ">=0.4.0" } }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/aes-js": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz", "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==", "optional": true }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "optional": true, "engines": { "node": ">= 14" } }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", "optional": true, "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" }, "engines": { "node": ">=8" } }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, "node_modules/amqplib": { "version": "0.10.9", "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.9.tgz", "integrity": "sha512-jwSftI4QjS3mizvnSnOrPGYiUnm1vI2OP1iXeOUz5pb74Ua0nbf6nPyyTzuiCLEE3fMpaJORXh2K/TQ08H5xGA==", "license": "MIT", "optional": true, "dependencies": { "buffer-more-ints": "~1.0.0", "url-parse": "~1.5.10" }, "engines": { "node": ">=10" } }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "engines": { "node": ">=8" } }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "dependencies": { "color-convert": "^2.0.1" }, "engines": { "node": ">=8" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "devOptional": true }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "devOptional": true, "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/array-includes": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/array.prototype.findlastindex": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", "dev": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/array.prototype.flat": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/array.prototype.flatmap": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/array.prototype.reduce": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.8.tgz", "integrity": "sha512-DwuEqgXFBwbmZSRqt3BpQigWNUoqw9Ml2dTWdF3B2zQlQX4OeUE0zyuzX0fX0IbTvjdkZbcBTU3idgpO78qkTw==", "optional": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-array-method-boxes-properly": "^1.0.0", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "is-string": "^1.1.1" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "devOptional": true, "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "dev": true }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "engines": { "node": ">=12" } }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "devOptional": true, "engines": { "node": ">= 0.4" } }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", "license": "MIT", "optional": true, "engines": { "node": ">=8.0.0" } }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "devOptional": true, "dependencies": { "possible-typed-array-names": "^1.0.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/balanced-match": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", "devOptional": true, "engines": { "node": "20 || >=22" } }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "optional": true }, "node_modules/base64url": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", "optional": true, "engines": { "node": ">=6.0.0" } }, "node_modules/baseline-browser-mapping": { "version": "2.9.15", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", "dev": true, "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "node_modules/bignumber.js": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz", "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==", "optional": true, "engines": { "node": "*" } }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" }, "engines": { "node": ">=18" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "dev": true }, "node_modules/bowser": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", "optional": true }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { "node": "18 || 20 || >=22" } }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { "fill-range": "^7.1.1" }, "engines": { "node": ">=8" } }, "node_modules/browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { "type": "opencollective", "url": "https://opencollective.com/browserslist" }, { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/browserslist" }, { "type": "github", "url": "https://github.com/sponsors/ai" } ], "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" }, "engines": { "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, "node_modules/bson": { "version": "4.7.2", "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.2.tgz", "integrity": "sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==", "optional": true, "dependencies": { "buffer": "^5.6.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, "node_modules/buffer-more-ints": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz", "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==", "license": "MIT", "optional": true }, "node_modules/bufferlist": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bufferlist/-/bufferlist-0.1.0.tgz", "integrity": "sha512-cg77q4YhmxV+/e7WhrShHGyOgey0GowZwg0cfXKCz5v3wfx73pUyrsnLnGY7DQZnjhtvmAaSMrf045zuJpDGDg==", "optional": true, "engines": { "node": "*" } }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", "dependencies": { "streamsearch": "^1.1.0" }, "engines": { "node": ">=10.16.0" } }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "engines": { "node": ">= 0.8" } }, "node_modules/cacache": { "version": "20.0.3", "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", "integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==", "optional": true, "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^13.0.0", "unique-filename": "^5.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", "optional": true, "engines": { "node": ">=10.6.0" } }, "node_modules/cacheable-request": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", "optional": true, "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" }, "engines": { "node": ">=8" } }, "node_modules/cacheable-request/node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", "optional": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "devOptional": true, "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/call-bound": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "engines": { "node": ">=6" } }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", "dev": true, "dependencies": { "browserslist": "^4.0.0", "caniuse-lite": "^1.0.0", "lodash.memoize": "^4.1.2", "lodash.uniq": "^4.5.0" } }, "node_modules/caniuse-lite": { "version": "1.0.30001764", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", "dev": true, "funding": [ { "type": "opencollective", "url": "https://opencollective.com/browserslist" }, { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/caniuse-lite" }, { "type": "github", "url": "https://github.com/sponsors/ai" } ] }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "engines": { "node": ">=18" } }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "dependencies": { "readdirp": "^4.0.1" }, "engines": { "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "optional": true, "engines": { "node": ">=18" } }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "optional": true, "engines": { "node": ">=6" } }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" }, "engines": { "node": ">=12" } }, "node_modules/clone": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", "optional": true, "engines": { "node": ">=0.8" } }, "node_modules/clone-response": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", "optional": true, "dependencies": { "mimic-response": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", "optional": true, "engines": { "node": ">=0.10.0" } }, "node_modules/color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" }, "engines": { "node": ">=18" } }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "dependencies": { "color-name": "~1.1.4" }, "engines": { "node": ">=7.0.0" } }, "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, "node_modules/color-string": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", "dependencies": { "color-name": "^2.0.0" }, "engines": { "node": ">=18" } }, "node_modules/color-string/node_modules/color-name": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", "engines": { "node": ">=12.20" } }, "node_modules/color/node_modules/color-convert": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", "dependencies": { "color-name": "^2.0.0" }, "engines": { "node": ">=14.6" } }, "node_modules/color/node_modules/color-name": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", "engines": { "node": ">=12.20" } }, "node_modules/colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", "dev": true }, "node_modules/colorette": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", "optional": true }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, "engines": { "node": ">= 0.8" } }, "node_modules/commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", "optional": true, "engines": { "node": ">=14" } }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", "dev": true, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", "engines": [ "node >= 6.0" ], "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "node_modules/concat-with-sourcemaps": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz", "integrity": "sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==", "dev": true, "dependencies": { "source-map": "^0.6.1" } }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "engines": { "node": ">=18" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "engines": { "node": ">= 0.6" } }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "engines": { "node": ">= 0.6" } }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "engines": { "node": ">=6.6.0" } }, "node_modules/cookiejar": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" }, "engines": { "node": ">= 8" } }, "node_modules/css-declaration-sorter": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", "integrity": "sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==", "dev": true, "engines": { "node": "^10 || ^12 || >=14" }, "peerDependencies": { "postcss": "^8.0.9" } }, "node_modules/css-select": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", "dev": true, "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.0.1", "domhandler": "^4.3.1", "domutils": "^2.8.0", "nth-check": "^2.0.1" }, "funding": { "url": "https://github.com/sponsors/fb55" } }, "node_modules/css-tree": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", "dev": true, "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" }, "engines": { "node": ">=8.0.0" } }, "node_modules/css-what": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", "dev": true, "engines": { "node": ">= 6" }, "funding": { "url": "https://github.com/sponsors/fb55" } }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, "bin": { "cssesc": "bin/cssesc" }, "engines": { "node": ">=4" } }, "node_modules/cssnano": { "version": "5.1.15", "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.15.tgz", "integrity": "sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==", "dev": true, "dependencies": { "cssnano-preset-default": "^5.2.14", "lilconfig": "^2.0.3", "yaml": "^1.10.2" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/cssnano" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/cssnano-preset-default": { "version": "5.2.14", "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz", "integrity": "sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==", "dev": true, "dependencies": { "css-declaration-sorter": "^6.3.1", "cssnano-utils": "^3.1.0", "postcss-calc": "^8.2.3", "postcss-colormin": "^5.3.1", "postcss-convert-values": "^5.1.3", "postcss-discard-comments": "^5.1.2", "postcss-discard-duplicates": "^5.1.0", "postcss-discard-empty": "^5.1.1", "postcss-discard-overridden": "^5.1.0", "postcss-merge-longhand": "^5.1.7", "postcss-merge-rules": "^5.1.4", "postcss-minify-font-values": "^5.1.0", "postcss-minify-gradients": "^5.1.1", "postcss-minify-params": "^5.1.4", "postcss-minify-selectors": "^5.2.1", "postcss-normalize-charset": "^5.1.0", "postcss-normalize-display-values": "^5.1.0", "postcss-normalize-positions": "^5.1.1", "postcss-normalize-repeat-style": "^5.1.1", "postcss-normalize-string": "^5.1.0", "postcss-normalize-timing-functions": "^5.1.0", "postcss-normalize-unicode": "^5.1.1", "postcss-normalize-url": "^5.1.0", "postcss-normalize-whitespace": "^5.1.1", "postcss-ordered-values": "^5.1.3", "postcss-reduce-initial": "^5.1.2", "postcss-reduce-transforms": "^5.1.0", "postcss-svgo": "^5.1.0", "postcss-unique-selectors": "^5.1.1" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/cssnano-utils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/csso": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", "dev": true, "dependencies": { "css-tree": "^1.1.2" }, "engines": { "node": ">=8.0.0" } }, "node_modules/csv-parse": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz", "integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==" }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", "engines": { "node": ">= 12" } }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "devOptional": true, "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/data-view-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "devOptional": true, "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/inspect-js" } }, "node_modules/data-view-byte-offset": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "devOptional": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dependencies": { "ms": "^2.1.3" }, "engines": { "node": ">=6.0" }, "peerDependenciesMeta": { "supports-color": { "optional": true } } }, "node_modules/decamelize": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", "dev": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "optional": true, "dependencies": { "mimic-response": "^3.1.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/decompress-response/node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "optional": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, "engines": { "node": ">=0.10.0" } }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", "optional": true, "engines": { "node": ">=10" } }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "devOptional": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "devOptional": true, "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, "engines": { "node": ">=0.4.0" } }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "engines": { "node": ">= 0.8" } }, "node_modules/detect-indent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.2.tgz", "integrity": "sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==", "dev": true, "engines": { "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/detect-newline": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-4.0.1.tgz", "integrity": "sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==", "dev": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", "dev": true, "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "node_modules/diff": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "dev": true, "engines": { "node": ">=0.3.1" } }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, "dependencies": { "esutils": "^2.0.2" }, "engines": { "node": ">=0.10.0" } }, "node_modules/dom-serializer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", "dev": true, "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" }, "funding": { "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/fb55" } ] }, "node_modules/domhandler": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", "dev": true, "dependencies": { "domelementtype": "^2.2.0" }, "engines": { "node": ">= 4" }, "funding": { "url": "https://github.com/fb55/domhandler?sponsor=1" } }, "node_modules/domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", "dev": true, "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", "domhandler": "^4.2.0" }, "funding": { "url": "https://github.com/fb55/domutils?sponsor=1" } }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "dev": true }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, "node_modules/enabled": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "engines": { "node": ">= 0.8" } }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "optional": true, "dependencies": { "iconv-lite": "^0.6.2" } }, "node_modules/encoding/node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" } }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "optional": true, "dependencies": { "once": "^1.4.0" } }, "node_modules/entities": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", "dev": true, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "optional": true, "engines": { "node": ">=6" } }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "optional": true }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "devOptional": true, "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/es-array-method-boxes-properly": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", "optional": true }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "engines": { "node": ">= 0.4" } }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "engines": { "node": ">= 0.4" } }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "dependencies": { "es-errors": "^1.3.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/es-set-tostringtag": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "devOptional": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/es-shim-unscopables": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "dependencies": { "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/es-to-primitive": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "devOptional": true, "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, "engines": { "node": ">=18" }, "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "devOptional": true, "engines": { "node": ">=6" } }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/eslint": { "version": "9.39.2", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://eslint.org/donate" }, "peerDependencies": { "jiti": "*" }, "peerDependenciesMeta": { "jiti": { "optional": true } } }, "node_modules/eslint-config-prettier": { "version": "10.1.8", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, "funding": { "url": "https://opencollective.com/eslint-config-prettier" }, "peerDependencies": { "eslint": ">=7.0.0" } }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", "dev": true, "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "dependencies": { "ms": "^2.1.1" } }, "node_modules/eslint-module-utils": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "dev": true, "dependencies": { "debug": "^3.2.7" }, "engines": { "node": ">=4" }, "peerDependenciesMeta": { "eslint": { "optional": true } } }, "node_modules/eslint-module-utils/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "dependencies": { "ms": "^2.1.1" } }, "node_modules/eslint-plugin-es": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz", "integrity": "sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==", "dev": true, "dependencies": { "eslint-utils": "^2.0.0", "regexpp": "^3.0.0" }, "engines": { "node": ">=8.10.0" }, "funding": { "url": "https://github.com/sponsors/mysticatea" }, "peerDependencies": { "eslint": ">=4.19.1" } }, "node_modules/eslint-plugin-import": { "version": "2.32.0", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "engines": { "node": ">=4" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "dependencies": { "ms": "^2.1.1" } }, "node_modules/eslint-plugin-import/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" } }, "node_modules/eslint-plugin-node": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz", "integrity": "sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==", "dev": true, "dependencies": { "eslint-plugin-es": "^3.0.0", "eslint-utils": "^2.0.0", "ignore": "^5.1.1", "minimatch": "^3.0.4", "resolve": "^1.10.1", "semver": "^6.1.0" }, "engines": { "node": ">=8.10.0" }, "peerDependencies": { "eslint": ">=5.16.0" } }, "node_modules/eslint-plugin-node/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "engines": { "node": ">= 4" } }, "node_modules/eslint-plugin-node/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" } }, "node_modules/eslint-plugin-prettier": { "version": "5.5.5", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", "dev": true, "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.12" }, "engines": { "node": "^14.18.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint-plugin-prettier" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "peerDependenciesMeta": { "@types/eslint": { "optional": true }, "eslint-config-prettier": { "optional": true } } }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", "dev": true, "dependencies": { "eslint-visitor-keys": "^1.1.0" }, "engines": { "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/mysticatea" } }, "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", "dev": true, "engines": { "node": ">=4" } }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "engines": { "node": ">= 4" } }, "node_modules/esm": { "version": "3.2.25", "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", "optional": true, "engines": { "node": ">=6" } }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "dependencies": { "estraverse": "^5.1.0" }, "engines": { "node": ">=0.10" } }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "dependencies": { "estraverse": "^5.2.0" }, "engines": { "node": ">=4.0" } }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "engines": { "node": ">=4.0" } }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "engines": { "node": ">=0.10.0" } }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "engines": { "node": ">= 0.6" } }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "dev": true }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "optional": true }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" }, "engines": { "node": ">= 18" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/express-openid-connect": { "version": "2.19.4", "resolved": "https://registry.npmjs.org/express-openid-connect/-/express-openid-connect-2.19.4.tgz", "integrity": "sha512-3YFPZ4MgUPhwfHbCaJKEij7uTc0vF4KpGKsuc3D1IhNMooiP6w8p1HBaaDQOE2KaAas22UghxVECxPpcC/gfOA==", "optional": true, "dependencies": { "base64url": "^3.0.1", "clone": "^2.1.2", "cookie": "^0.7.2", "debug": "^4.4.1", "futoin-hkdf": "^1.5.3", "http-errors": "^1.8.1", "joi": "^17.13.3", "jose": "^2.0.7", "on-headers": "^1.1.0", "openid-client": "^4.9.1", "url-join": "^4.0.1", "util-promisify": "3.0.0" }, "engines": { "node": "^10.19.0 || >=12.0.0 < 13 || >=13.7.0 < 14 || >= 14.2.0" }, "peerDependencies": { "express": ">= 4.17.0" } }, "node_modules/express-openid-connect/node_modules/depd": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", "optional": true, "engines": { "node": ">= 0.6" } }, "node_modules/express-openid-connect/node_modules/http-errors": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "optional": true, "dependencies": { "depd": "~1.1.2", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": ">= 1.5.0 < 2", "toidentifier": "1.0.1" }, "engines": { "node": ">= 0.6" } }, "node_modules/express-openid-connect/node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "optional": true, "engines": { "node": ">= 0.6" } }, "node_modules/express-rate-limit": { "version": "8.5.2", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "dependencies": { "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" }, "funding": { "url": "https://github.com/sponsors/express-rate-limit" }, "peerDependencies": { "express": ">= 4.11" } }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, "node_modules/fast-diff": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" }, "engines": { "node": ">=8.6.0" } }, "node_modules/fast-glob/node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "dependencies": { "is-glob": "^4.0.1" }, "engines": { "node": ">= 6" } }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "dev": true }, "node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], "license": "MIT", "optional": true, "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "node_modules/fast-xml-parser": { "version": "5.7.2", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], "license": "MIT", "optional": true, "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "node_modules/fastcgi-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fastcgi-stream/-/fastcgi-stream-1.0.0.tgz", "integrity": "sha512-esJ75qjKZmh+4lXuNCGqnNkMt2LW5aB6WVDkkVBRL/Igxr3uB+KJhIjSr4Cf875RJAXiJAQVK5B9PSdcRPg5Dg==", "optional": true, "dependencies": { "bufferlist": ">= 0.0.6" }, "engines": { "node": ">= 0.10.0" } }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "dependencies": { "reusify": "^1.0.4" } }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "devOptional": true, "engines": { "node": ">=12.0.0" }, "peerDependencies": { "picomatch": "^3 || ^4" }, "peerDependenciesMeta": { "picomatch": { "optional": true } } }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/jimmywarting" }, { "type": "paypal", "url": "https://paypal.me/jimmywarting" } ], "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" }, "engines": { "node": "^12.20 || >= 14.13" } }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "dependencies": { "flat-cache": "^4.0.0" }, "engines": { "node": ">=16.0.0" } }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, "engines": { "node": ">=8" } }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" }, "engines": { "node": ">= 18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/flat": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", "dev": true, "bin": { "flat": "cli.js" } }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" }, "engines": { "node": ">=16" } }, "node_modules/flatted": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, "node_modules/fn.name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "devOptional": true, "dependencies": { "is-callable": "^1.2.7" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" } }, "node_modules/form-data/node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "engines": { "node": ">= 0.6" } }, "node_modules/form-data/node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "dependencies": { "mime-db": "1.52.0" }, "engines": { "node": ">= 0.6" } }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", "dependencies": { "fetch-blob": "^3.1.2" }, "engines": { "node": ">=12.20.0" } }, "node_modules/formidable": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", "dev": true, "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" }, "engines": { "node": ">=14.0.0" }, "funding": { "url": "https://ko-fi.com/tunnckoCore/commissions" } }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "engines": { "node": ">= 0.6" } }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "engines": { "node": ">= 0.8" } }, "node_modules/fs-minipass": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", "optional": true, "dependencies": { "minipass": "^7.0.3" }, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, "optional": true, "os": [ "darwin" ], "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/function.prototype.name": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "devOptional": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/functions-have-names": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "devOptional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/futoin-hkdf": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.5.3.tgz", "integrity": "sha512-SewY5KdMpaoCeh7jachEWFsh1nNlaDjNHZXWqL5IGwtpEYHTgkr2+AMCgNwKWkcc0wpSYrZfR7he4WdmHFtDxQ==", "optional": true, "engines": { "node": ">=8" } }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", "devOptional": true, "engines": { "node": ">= 0.4" } }, "node_modules/generic-names": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-4.0.0.tgz", "integrity": "sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==", "dev": true, "dependencies": { "loader-utils": "^3.2.0" } }, "node_modules/generic-pool": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", "optional": true, "engines": { "node": ">= 4" } }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, "engines": { "node": "6.* || 8.* || >= 10.*" } }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "optional": true, "engines": { "node": ">=8.0.0" } }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", "optional": true, "dependencies": { "pump": "^3.0.0" }, "engines": { "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "devOptional": true, "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/getopts": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==", "optional": true }, "node_modules/git-hooks-list": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/git-hooks-list/-/git-hooks-list-4.1.1.tgz", "integrity": "sha512-cmP497iLq54AZnv4YRAEMnEyQ1eIn4tGKbmswqwmFV4GBnAqE8NLtWxxdXa++AalfgL5EBH4IxTPyquEuGY/jA==", "dev": true, "funding": { "url": "https://github.com/fisker/git-hooks-list?sponsor=1" } }, "node_modules/glob": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", "devOptional": true, "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" }, "engines": { "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "dependencies": { "is-glob": "^4.0.3" }, "engines": { "node": ">=10.13.0" } }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "engines": { "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "devOptional": true, "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/got": { "version": "11.8.6", "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", "optional": true, "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" }, "engines": { "node": ">=10.19.0" }, "funding": { "url": "https://github.com/sindresorhus/got?sponsor=1" } }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "optional": true }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "devOptional": true, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "engines": { "node": ">=8" } }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "devOptional": true, "dependencies": { "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-proto": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "devOptional": true, "dependencies": { "dunder-proto": "^1.0.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-tostringtag": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "devOptional": true, "dependencies": { "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true, "bin": { "he": "bin/he" } }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "optional": true }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "optional": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" }, "engines": { "node": ">= 14" } }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", "optional": true, "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" }, "engines": { "node": ">=10.19.0" } }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "optional": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" }, "engines": { "node": ">= 14" } }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/icss-replace-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz", "integrity": "sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==", "dev": true }, "node_modules/icss-utils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", "dev": true, "engines": { "node": "^10 || ^12 || >= 14" }, "peerDependencies": { "postcss": "^8.1.0" } }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "optional": true }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "engines": { "node": ">= 4" } }, "node_modules/import-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz", "integrity": "sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==", "dev": true, "dependencies": { "import-from": "^3.0.0" }, "engines": { "node": ">=8" } }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" }, "engines": { "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/import-from": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/import-from/-/import-from-3.0.0.tgz", "integrity": "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==", "dev": true, "dependencies": { "resolve-from": "^5.0.0" }, "engines": { "node": ">=8" } }, "node_modules/import-from/node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, "engines": { "node": ">=8" } }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "devOptional": true, "engines": { "node": ">=0.8.19" } }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "optional": true, "engines": { "node": ">=8" } }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ini/-/ini-3.0.1.tgz", "integrity": "sha512-it4HyVAUTKBc6m8e1iXWvXSTdndF7HbdN713+kvLrymxTaU4AUBWrJ4vEooP+V7fexnVD3LKcBshjGGPefSMUQ==", "optional": true, "engines": { "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, "node_modules/install-artifact-from-github": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/install-artifact-from-github/-/install-artifact-from-github-1.4.0.tgz", "integrity": "sha512-+y6WywKZREw5rq7U2jvr2nmZpT7cbWbQQ0N/qfcseYnzHFz2cZz1Et52oY+XttYuYeTkI8Y+R2JNWj68MpQFSg==", "optional": true, "bin": { "install-from-cache": "bin/install-from-cache.js", "save-to-github-cache": "bin/save-to-github-cache.js" } }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "devOptional": true, "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/interpret": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", "optional": true, "engines": { "node": ">= 0.10" } }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" } }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", "optional": true, "engines": { "node": ">= 10" } }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "devOptional": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "devOptional": true, "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-bigint": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "devOptional": true, "dependencies": { "has-bigints": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "devOptional": true, "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "devOptional": true, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "devOptional": true, "dependencies": { "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-data-view": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "devOptional": true, "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-date-object": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "devOptional": true, "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "engines": { "node": ">=0.10.0" } }, "node_modules/is-finalizationregistry": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "devOptional": true, "dependencies": { "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "engines": { "node": ">=8" } }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "devOptional": true, "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, "engines": { "node": ">=0.10.0" } }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "devOptional": true, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", "dev": true }, "node_modules/is-negative-zero": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "devOptional": true, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-node-process": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", "dev": true }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "engines": { "node": ">=0.12.0" } }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "devOptional": true, "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "engines": { "node": ">=8" } }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "dev": true, "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" }, "node_modules/is-reference": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", "dev": true, "dependencies": { "@types/estree": "*" } }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "devOptional": true, "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-set": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "devOptional": true, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-shared-array-buffer": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "devOptional": true, "dependencies": { "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "engines": { "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "devOptional": true, "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-symbol": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "devOptional": true, "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "devOptional": true, "dependencies": { "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "devOptional": true, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-weakref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "devOptional": true, "dependencies": { "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-weakset": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "devOptional": true, "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "devOptional": true }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, "node_modules/joi": { "version": "17.13.4", "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz", "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", "license": "BSD-3-Clause", "optional": true, "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "node_modules/jose": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.7.tgz", "integrity": "sha512-5hFWIigKqC+e/lRyQhfnirrAqUdIPMB7SJRqflJaO29dW7q5DFvH1XCSTmv6PQ6pb++0k6MJlLRoS0Wv4s38Wg==", "optional": true, "dependencies": { "@panva/asn1.js": "^1.0.0" }, "engines": { "node": ">=10.13.0 < 13 || >=13.7.0" }, "funding": { "url": "https://github.com/sponsors/panva" } }, "node_modules/js-yaml": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "devOptional": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/puzrin" }, { "type": "github", "url": "https://github.com/sponsors/nodeca" } ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "devOptional": true }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "dev": true }, "node_modules/json5": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "devOptional": true, "dependencies": { "json-buffer": "3.0.1" } }, "node_modules/knex": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/knex/-/knex-2.5.1.tgz", "integrity": "sha512-z78DgGKUr4SE/6cm7ku+jHvFT0X97aERh/f0MUKAKgFnwCYBEW4TFBqtHWFYiJFid7fMrtpZ/gxJthvz5mEByA==", "optional": true, "dependencies": { "colorette": "2.0.19", "commander": "^10.0.0", "debug": "4.3.4", "escalade": "^3.1.1", "esm": "^3.2.25", "get-package-type": "^0.1.0", "getopts": "2.3.0", "interpret": "^2.2.0", "lodash": "^4.17.21", "pg-connection-string": "2.6.1", "rechoir": "^0.8.0", "resolve-from": "^5.0.0", "tarn": "^3.0.2", "tildify": "2.0.0" }, "bin": { "knex": "bin/cli.js" }, "engines": { "node": ">=12" }, "peerDependenciesMeta": { "better-sqlite3": { "optional": true }, "mysql": { "optional": true }, "mysql2": { "optional": true }, "pg": { "optional": true }, "pg-native": { "optional": true }, "sqlite3": { "optional": true }, "tedious": { "optional": true } } }, "node_modules/knex/node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "optional": true, "dependencies": { "ms": "2.1.2" }, "engines": { "node": ">=6.0" }, "peerDependenciesMeta": { "supports-color": { "optional": true } } }, "node_modules/knex/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "optional": true }, "node_modules/knex/node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "optional": true, "engines": { "node": ">=8" } }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, "node_modules/ldapts": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.3.tgz", "integrity": "sha512-kEU3GDh48ZymnyLGsFprai2v4r7Gyxe6niBlUUw3xnOGpq5O+XODmXJ8gBwbPIg35qt5cnYVC80NNSdAkb2dJg==", "dependencies": { "strict-event-emitter-types": "2.0.0", "whatwg-url": "15.1.0" }, "engines": { "node": ">=20" } }, "node_modules/lemonldap-ng-handler": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/lemonldap-ng-handler/-/lemonldap-ng-handler-0.7.3.tgz", "integrity": "sha512-ISYZj7C9O4g1cXGLAUwInXJenwvlUFkPG1ftoS/9IQqSKwBzvP/+1TkR/QoYEBLxbA9IbVC5+TAV5I8uT365bg==", "optional": true, "dependencies": { "@lemonldap-ng/conf": "^0.7.1", "@lemonldap-ng/logger": "^0.7.0", "@lemonldap-ng/safelib": "^0.7.0", "@lemonldap-ng/session": "^0.7.0", "node-fastcgi": "^1.3.3", "normalize-url": "^7.0.3", "re2": "^1.17.7" } }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" }, "engines": { "node": ">= 0.8.0" } }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", "dev": true, "engines": { "node": ">=10" } }, "node_modules/limited-cache": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/limited-cache/-/limited-cache-2.2.0.tgz", "integrity": "sha512-fN4i1+l3Mhuad/fNoIjsowyjeUC+Gz9kKmXE51GROFHUDxdP4nIc9lZTzcKS0uTtwpxL5mjJcNrs8sVSYwB4ng==", "optional": true }, "node_modules/loader-utils": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", "dev": true, "engines": { "node": ">= 12.13.0" } }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "dependencies": { "p-locate": "^5.0.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "optional": true }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "dev": true }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "dev": true }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" }, "engines": { "node": ">= 12.0.0" } }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", "optional": true, "engines": { "node": ">=8" } }, "node_modules/lru-cache": { "version": "11.2.4", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", "engines": { "node": "20 || >=22" } }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "optional": true }, "node_modules/make-fetch-happen": { "version": "15.0.3", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz", "integrity": "sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==", "optional": true, "dependencies": { "@npmcli/agent": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "promise-retry": "^2.0.1", "ssri": "^13.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "engines": { "node": ">= 0.4" } }, "node_modules/mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", "dev": true }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "engines": { "node": ">= 0.8" } }, "node_modules/memory-pager": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", "optional": true }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "engines": { "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, "engines": { "node": ">= 8" } }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "dev": true, "engines": { "node": ">= 0.6" } }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { "node": ">=8.6" } }, "node_modules/micromatch/node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "engines": { "node": ">=8.6" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", "dev": true, "bin": { "mime": "cli.js" }, "engines": { "node": ">=4.0.0" } }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "dependencies": { "mime-db": "^1.54.0" }, "engines": { "node": ">=18" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", "optional": true, "engines": { "node": ">=4" } }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "devOptional": true, "dependencies": { "brace-expansion": "^5.0.2" }, "engines": { "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "devOptional": true, "engines": { "node": ">=16 || 14 >=14.17" } }, "node_modules/minipass-collect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", "optional": true, "dependencies": { "minipass": "^7.0.3" }, "engines": { "node": ">=16 || 14 >=14.17" } }, "node_modules/minipass-fetch": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.0.tgz", "integrity": "sha512-fiCdUALipqgPWrOVTz9fw0XhcazULXOSU6ie40DDbX1F49p1dBrSRBuswndTx1x3vEb/g0FT7vC4c4C2u/mh3A==", "optional": true, "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "node_modules/minipass-flush": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", "optional": true, "dependencies": { "minipass": "^3.0.0" }, "engines": { "node": ">= 8" } }, "node_modules/minipass-flush/node_modules/minipass": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "optional": true, "dependencies": { "yallist": "^4.0.0" }, "engines": { "node": ">=8" } }, "node_modules/minipass-pipeline": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", "optional": true, "dependencies": { "minipass": "^3.0.0" }, "engines": { "node": ">=8" } }, "node_modules/minipass-pipeline/node_modules/minipass": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "optional": true, "dependencies": { "yallist": "^4.0.0" }, "engines": { "node": ">=8" } }, "node_modules/minipass-sized": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", "optional": true, "dependencies": { "minipass": "^3.0.0" }, "engines": { "node": ">=8" } }, "node_modules/minipass-sized/node_modules/minipass": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "optional": true, "dependencies": { "yallist": "^4.0.0" }, "engines": { "node": ">=8" } }, "node_modules/minizlib": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "optional": true, "dependencies": { "minipass": "^7.1.2" }, "engines": { "node": ">= 18" } }, "node_modules/mocha": { "version": "11.7.5", "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", "dev": true, "dependencies": { "browser-stdout": "^1.3.1", "chokidar": "^4.0.1", "debug": "^4.3.5", "diff": "^7.0.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", "glob": "^10.4.5", "he": "^1.2.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", "minimatch": "^9.0.5", "ms": "^2.1.3", "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", "workerpool": "^9.2.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" }, "bin": { "_mocha": "bin/_mocha", "mocha": "bin/mocha.js" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "dependencies": { "has-flag": "^4.0.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/mongodb": { "version": "4.17.2", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.17.2.tgz", "integrity": "sha512-mLV7SEiov2LHleRJPMPrK2PMyhXFZt2UQLC4VD4pnth3jMjYKHhtqfwwkkvS/NXuo/Fp3vbhaNcXrIDaLRb9Tg==", "optional": true, "dependencies": { "bson": "^4.7.2", "mongodb-connection-string-url": "^2.6.0", "socks": "^2.7.1" }, "engines": { "node": ">=12.9.0" }, "optionalDependencies": { "@aws-sdk/credential-providers": "^3.186.0", "@mongodb-js/saslprep": "^1.1.0" } }, "node_modules/mongodb-connection-string-url": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", "optional": true, "dependencies": { "@types/whatwg-url": "^8.2.1", "whatwg-url": "^11.0.0" } }, "node_modules/mongodb-connection-string-url/node_modules/tr46": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", "optional": true, "dependencies": { "punycode": "^2.1.1" }, "engines": { "node": ">=12" } }, "node_modules/mongodb-connection-string-url/node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "optional": true, "engines": { "node": ">=12" } }, "node_modules/mongodb-connection-string-url/node_modules/whatwg-url": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", "optional": true, "dependencies": { "tr46": "^3.0.0", "webidl-conversions": "^7.0.0" }, "engines": { "node": ">=12" } }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/multer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", "type-is": "^1.6.18" }, "engines": { "node": ">= 10.16.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/multer/node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "engines": { "node": ">= 0.6" } }, "node_modules/multer/node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "engines": { "node": ">= 0.6" } }, "node_modules/multer/node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dependencies": { "mime-db": "1.52.0" }, "engines": { "node": ">= 0.6" } }, "node_modules/multer/node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" }, "engines": { "node": ">= 0.6" } }, "node_modules/mysql": { "version": "2.18.1", "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.18.1.tgz", "integrity": "sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig==", "optional": true, "dependencies": { "bignumber.js": "9.0.0", "readable-stream": "2.3.7", "safe-buffer": "5.1.2", "sqlstring": "2.3.1" }, "engines": { "node": ">= 0.6" } }, "node_modules/mysql/node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "optional": true }, "node_modules/mysql/node_modules/readable-stream": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", "optional": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "node_modules/mysql/node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "optional": true, "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/nan": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", "optional": true }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, "engines": { "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "engines": { "node": ">= 0.6" } }, "node_modules/nock": { "version": "14.0.10", "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.10.tgz", "integrity": "sha512-Q7HjkpyPeLa0ZVZC5qpxBt5EyLczFJ91MEewQiIi9taWuA0KB/MDJlUWtON+7dGouVdADTQsf9RA7TZk6D8VMw==", "dev": true, "dependencies": { "@mswjs/interceptors": "^0.39.5", "json-stringify-safe": "^5.0.1", "propagate": "^2.0.0" }, "engines": { "node": ">=18.20.0 <20 || >=20.12.1" } }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", "funding": [ { "type": "github", "url": "https://github.com/sponsors/jimmywarting" }, { "type": "github", "url": "https://paypal.me/jimmywarting" } ], "engines": { "node": ">=10.5.0" } }, "node_modules/node-fastcgi": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/node-fastcgi/-/node-fastcgi-1.3.3.tgz", "integrity": "sha512-uIfm28LPIg81eD+epaaYTK9motZFxQvTpjtYa0b1lq7U9N+4EDJbPvQpBbVdhmTcQ5PWQgxwFJnp2XLK804NbA==", "optional": true, "dependencies": { "fastcgi-stream": "^1.0.0" }, "engines": { "node": ">= 0.12" } }, "node_modules/node-fetch": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" }, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/node-fetch" } }, "node_modules/node-gyp": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.1.0.tgz", "integrity": "sha512-W+RYA8jBnhSr2vrTtlPYPc1K+CSjGpVDRZxcqJcERZ8ND3A1ThWPHRwctTx3qC3oW99jt726jhdz3Y6ky87J4g==", "optional": true, "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^15.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.2", "tinyglobby": "^0.2.12", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/node-gyp/node_modules/isexe": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", "optional": true, "engines": { "node": ">=16" } }, "node_modules/node-gyp/node_modules/which": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==", "optional": true, "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/node-persist": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/node-persist/-/node-persist-3.1.3.tgz", "integrity": "sha512-CaFv+kSZtsc+VeDRldK1yR47k1vPLBpzYB9re2z7LIwITxwBtljMq3s8VQnnr+x3E8pQfHbc5r2IyJsBLJhtXg==", "optional": true, "engines": { "node": ">=10.12.0" } }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true }, "node_modules/nopt": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "optional": true, "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-url": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-7.2.0.tgz", "integrity": "sha512-uhXOdZry0L6M2UIo9BTt7FdpBDiAGN/7oItedQwPKh8jh31ZlvC8U9Xl/EJ3aijDHaywXTW3QbZ6LuCocur1YA==", "optional": true, "engines": { "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", "dev": true, "dependencies": { "boolbase": "^1.0.0" }, "funding": { "url": "https://github.com/fb55/nth-check?sponsor=1" } }, "node_modules/object-hash": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", "optional": true, "engines": { "node": ">= 6" } }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "devOptional": true, "engines": { "node": ">= 0.4" } }, "node_modules/object.assign": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "devOptional": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/object.fromentries": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/object.getownpropertydescriptors": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.9.tgz", "integrity": "sha512-mt8YM6XwsTTovI+kdZdHSxoyF2DI59up034orlC9NfweclcWOt7CVascNNLp6U+bjFVCVCIh9PwS76tDM/rH8g==", "optional": true, "dependencies": { "array.prototype.reduce": "^1.0.8", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "gopd": "^1.2.0", "safe-array-concat": "^1.1.3" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/object.groupby": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", "dev": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/object.values": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/oidc-token-hash": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", "optional": true, "engines": { "node": "^10.13.0 || >=12.0.0" } }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", "license": "MIT", "optional": true, "engines": { "node": ">=14.0.0" } }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "dependencies": { "ee-first": "1.1.1" }, "engines": { "node": ">= 0.8" } }, "node_modules/on-headers": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "optional": true, "engines": { "node": ">= 0.8" } }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dependencies": { "wrappy": "1" } }, "node_modules/one-time": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", "dependencies": { "fn.name": "1.x.x" } }, "node_modules/openid-client": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.9.1.tgz", "integrity": "sha512-DYUF07AHjI3QDKqKbn2F7RqozT4hyi4JvmpodLrq0HHoNP7t/AjeG/uqiBK1/N2PZSAQEThVjDLHSmJN4iqu/w==", "optional": true, "dependencies": { "aggregate-error": "^3.1.0", "got": "^11.8.0", "jose": "^2.0.5", "lru-cache": "^6.0.0", "make-error": "^1.3.6", "object-hash": "^2.0.1", "oidc-token-hash": "^5.0.1" }, "engines": { "node": "^10.19.0 || >=12.0.0 < 13 || >=13.7.0 < 14 || >= 14.2.0" }, "funding": { "url": "https://github.com/sponsors/panva" } }, "node_modules/openid-client/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "optional": true, "dependencies": { "yallist": "^4.0.0" }, "engines": { "node": ">=10" } }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" }, "engines": { "node": ">= 0.8.0" } }, "node_modules/outvariant": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", "dev": true }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "devOptional": true, "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", "optional": true, "engines": { "node": ">=8" } }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", "dev": true, "engines": { "node": ">=4" } }, "node_modules/p-limit": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.2.0.tgz", "integrity": "sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ==", "dependencies": { "yocto-queue": "^1.2.1" }, "engines": { "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-locate": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "dependencies": { "p-limit": "^3.0.2" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-locate/node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-locate/node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-map": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", "optional": true, "engines": { "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-queue": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", "dev": true, "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" }, "engines": { "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-timeout": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", "dev": true, "dependencies": { "p-finally": "^1.0.0" }, "engines": { "node": ">=8" } }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "dependencies": { "callsites": "^3.0.0" }, "engines": { "node": ">=6" } }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "engines": { "node": ">= 0.8" } }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "engines": { "node": ">=8" } }, "node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], "license": "MIT", "optional": true, "engines": { "node": ">=14.0.0" } }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "engines": { "node": ">=8" } }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "devOptional": true }, "node_modules/path-scurry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", "devOptional": true, "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" }, "engines": { "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-to-regexp": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/perl-dbi": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/perl-dbi/-/perl-dbi-0.7.0.tgz", "integrity": "sha512-L9qsUtriNzwXEwRwhIUF6AT5iXPttgIa1/x6x4H6qpVfEGu6QFumYuYi+mduuwPopSBS0ZoqCNiF3VYfzSjG9w==", "optional": true, "dependencies": { "knex": "^2.1.0" } }, "node_modules/pg": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/pg/-/pg-8.17.1.tgz", "integrity": "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ==", "optional": true, "dependencies": { "pg-connection-string": "^2.10.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "engines": { "node": ">= 16.0.0" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "peerDependenciesMeta": { "pg-native": { "optional": true } } }, "node_modules/pg-cloudflare": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", "optional": true }, "node_modules/pg-connection-string": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==", "optional": true }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", "optional": true, "engines": { "node": ">=4.0.0" } }, "node_modules/pg-pool": { "version": "3.11.0", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", "optional": true, "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", "optional": true }, "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", "optional": true, "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" }, "engines": { "node": ">=4" } }, "node_modules/pg/node_modules/pg-connection-string": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.0.tgz", "integrity": "sha512-ur/eoPKzDx2IjPaYyXS6Y8NSblxM7X64deV2ObV57vhjsWiwLvUD6meukAzogiOsu60GO8m/3Cb6FdJsWNjwXg==", "optional": true }, "node_modules/pgpass": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", "optional": true, "dependencies": { "split2": "^4.1.0" } }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "devOptional": true, "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/pify": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz", "integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==", "dev": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/pino": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", "license": "MIT", "optional": true, "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "node_modules/pino-abstract-transport": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", "license": "MIT", "optional": true, "dependencies": { "split2": "^4.0.0" } }, "node_modules/pino-std-serializers": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT", "optional": true }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "devOptional": true, "engines": { "node": ">= 0.4" } }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { "type": "opencollective", "url": "https://opencollective.com/postcss/" }, { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" }, { "type": "github", "url": "https://github.com/sponsors/ai" } ], "license": "MIT", "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" } }, "node_modules/postcss-calc": { "version": "8.2.4", "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.9", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.2.2" } }, "node_modules/postcss-colormin": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.1.tgz", "integrity": "sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==", "dev": true, "dependencies": { "browserslist": "^4.21.4", "caniuse-api": "^3.0.0", "colord": "^2.9.1", "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-convert-values": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz", "integrity": "sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==", "dev": true, "dependencies": { "browserslist": "^4.21.4", "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-discard-comments": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-discard-duplicates": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-discard-empty": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-discard-overridden": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-load-config": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", "dev": true, "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "engines": { "node": ">= 10" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/postcss/" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "peerDependenciesMeta": { "postcss": { "optional": true }, "ts-node": { "optional": true } } }, "node_modules/postcss-merge-longhand": { "version": "5.1.7", "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz", "integrity": "sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0", "stylehacks": "^5.1.1" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-merge-rules": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz", "integrity": "sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==", "dev": true, "dependencies": { "browserslist": "^4.21.4", "caniuse-api": "^3.0.0", "cssnano-utils": "^3.1.0", "postcss-selector-parser": "^6.0.5" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-minify-font-values": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-minify-gradients": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", "dev": true, "dependencies": { "colord": "^2.9.1", "cssnano-utils": "^3.1.0", "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-minify-params": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz", "integrity": "sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==", "dev": true, "dependencies": { "browserslist": "^4.21.4", "cssnano-utils": "^3.1.0", "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-minify-selectors": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz", "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==", "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.5" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-modules": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/postcss-modules/-/postcss-modules-4.3.1.tgz", "integrity": "sha512-ItUhSUxBBdNamkT3KzIZwYNNRFKmkJrofvC2nWab3CPKhYBQ1f27XXh1PAPE27Psx58jeelPsxWB/+og+KEH0Q==", "dev": true, "dependencies": { "generic-names": "^4.0.0", "icss-replace-symbols": "^1.1.0", "lodash.camelcase": "^4.3.0", "postcss-modules-extract-imports": "^3.0.0", "postcss-modules-local-by-default": "^4.0.0", "postcss-modules-scope": "^3.0.0", "postcss-modules-values": "^4.0.0", "string-hash": "^1.1.1" }, "peerDependencies": { "postcss": "^8.0.0" } }, "node_modules/postcss-modules-extract-imports": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", "dev": true, "engines": { "node": "^10 || ^12 || >= 14" }, "peerDependencies": { "postcss": "^8.1.0" } }, "node_modules/postcss-modules-local-by-default": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", "dev": true, "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^7.0.0", "postcss-value-parser": "^4.1.0" }, "engines": { "node": "^10 || ^12 || >= 14" }, "peerDependencies": { "postcss": "^8.1.0" } }, "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" }, "engines": { "node": ">=4" } }, "node_modules/postcss-modules-scope": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", "dev": true, "dependencies": { "postcss-selector-parser": "^7.0.0" }, "engines": { "node": "^10 || ^12 || >= 14" }, "peerDependencies": { "postcss": "^8.1.0" } }, "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" }, "engines": { "node": ">=4" } }, "node_modules/postcss-modules-values": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", "dev": true, "dependencies": { "icss-utils": "^5.0.0" }, "engines": { "node": "^10 || ^12 || >= 14" }, "peerDependencies": { "postcss": "^8.1.0" } }, "node_modules/postcss-normalize-charset": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-normalize-display-values": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-normalize-positions": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz", "integrity": "sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-normalize-repeat-style": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz", "integrity": "sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-normalize-string": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-normalize-timing-functions": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-normalize-unicode": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz", "integrity": "sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==", "dev": true, "dependencies": { "browserslist": "^4.21.4", "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-normalize-url": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", "dev": true, "dependencies": { "normalize-url": "^6.0.1", "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-normalize-url/node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", "dev": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/postcss-normalize-whitespace": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-ordered-values": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz", "integrity": "sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==", "dev": true, "dependencies": { "cssnano-utils": "^3.1.0", "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-reduce-initial": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz", "integrity": "sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==", "dev": true, "dependencies": { "browserslist": "^4.21.4", "caniuse-api": "^3.0.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-reduce-transforms": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-selector-parser": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" }, "engines": { "node": ">=4" } }, "node_modules/postcss-svgo": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0", "svgo": "^2.7.0" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-unique-selectors": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.5" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "optional": true, "engines": { "node": ">=4" } }, "node_modules/postgres-bytea": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", "optional": true, "engines": { "node": ">=0.10.0" } }, "node_modules/postgres-date": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "optional": true, "engines": { "node": ">=0.10.0" } }, "node_modules/postgres-interval": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "optional": true, "dependencies": { "xtend": "^4.0.0" }, "engines": { "node": ">=0.10.0" } }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "engines": { "node": ">= 0.8.0" } }, "node_modules/prettier": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.0.tgz", "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" }, "engines": { "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" } }, "node_modules/prettier-linter-helpers": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", "dev": true, "dependencies": { "fast-diff": "^1.1.2" }, "engines": { "node": ">=6.0.0" } }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "optional": true, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "optional": true }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/fastify" }, { "type": "opencollective", "url": "https://opencollective.com/fastify" } ], "license": "MIT", "optional": true }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", "optional": true, "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" }, "engines": { "node": ">=10" } }, "node_modules/promise.series": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/promise.series/-/promise.series-0.2.0.tgz", "integrity": "sha512-VWQJyU2bcDTgZw8kpfBpB/ejZASlCrzwz5f2hjb/zlujOEB4oeiAhHygAWq8ubsX2GVkD4kCU5V2dwOTaCY5EQ==", "dev": true, "engines": { "node": ">=0.12" } }, "node_modules/propagate": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", "dev": true, "engines": { "node": ">= 8" } }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" }, "engines": { "node": ">= 0.10" } }, "node_modules/proxy-addr/node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "engines": { "node": ">= 0.10" } }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "optional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "engines": { "node": ">=6" } }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "license": "MIT", "optional": true }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ] }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT", "optional": true }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", "optional": true, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", "optional": true, "engines": { "node": ">= 0.8" } }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "engines": { "node": ">= 0.6" } }, "node_modules/raw-body": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.10" } }, "node_modules/re2": { "version": "1.23.0", "resolved": "https://registry.npmjs.org/re2/-/re2-1.23.0.tgz", "integrity": "sha512-mT7+/Lz+Akjm/C/X6PiaHihcJL92TNNXai/C4c/dfBbhtwMm1uKEEoA2Lz/FF6aBFfQzg5mAyv4BGjM4q44QwQ==", "hasInstallScript": true, "optional": true, "dependencies": { "install-artifact-from-github": "^1.4.0", "nan": "^2.24.0", "node-gyp": "^12.1.0" } }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" }, "engines": { "node": ">= 6" } }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, "engines": { "node": ">= 14.18.0" }, "funding": { "type": "individual", "url": "https://paulmillr.com/funding/" } }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", "license": "MIT", "optional": true, "engines": { "node": ">= 12.13.0" } }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", "optional": true, "dependencies": { "resolve": "^1.20.0" }, "engines": { "node": ">= 10.13.0" } }, "node_modules/redis": { "version": "4.7.1", "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", "optional": true, "dependencies": { "@redis/bloom": "1.2.0", "@redis/client": "1.6.1", "@redis/graph": "1.1.1", "@redis/json": "1.0.7", "@redis/search": "1.2.0", "@redis/time-series": "1.1.0" } }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "devOptional": true, "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "devOptional": true, "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/regexpp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", "dev": true, "engines": { "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/mysticatea" } }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, "engines": { "node": ">=0.10.0" } }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT", "optional": true }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "devOptional": true, "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", "optional": true }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "engines": { "node": ">=4" } }, "node_modules/responselike": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", "optional": true, "dependencies": { "lowercase-keys": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "optional": true, "engines": { "node": ">= 4" } }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" } }, "node_modules/rimraf": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", "dev": true, "dependencies": { "glob": "^13.0.0", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" }, "engines": { "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "dependencies": { "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" }, "engines": { "node": ">=18.0.0", "npm": ">=8.0.0" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, "node_modules/rollup-plugin-postcss": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-4.0.2.tgz", "integrity": "sha512-05EaY6zvZdmvPUDi3uCcAQoESDcYnv8ogJJQRp6V5kZ6J6P7uAVJlrTZcaaA20wTH527YTnKfkAoPxWI/jPp4w==", "dev": true, "dependencies": { "chalk": "^4.1.0", "concat-with-sourcemaps": "^1.1.0", "cssnano": "^5.0.1", "import-cwd": "^3.0.0", "p-queue": "^6.6.2", "pify": "^5.0.0", "postcss-load-config": "^3.0.0", "postcss-modules": "^4.0.0", "promise.series": "^0.2.0", "resolve": "^1.19.0", "rollup-pluginutils": "^2.8.2", "safe-identifier": "^0.4.2", "style-inject": "^0.3.0" }, "engines": { "node": ">=10" }, "peerDependencies": { "postcss": "8.x" } }, "node_modules/rollup-pluginutils": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", "dev": true, "dependencies": { "estree-walker": "^0.6.1" } }, "node_modules/rollup-pluginutils/node_modules/estree-walker": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", "dev": true }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" }, "engines": { "node": ">= 18" } }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "dependencies": { "queue-microtask": "^1.2.2" } }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "devOptional": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, "engines": { "node": ">=0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "optional": true }, "node_modules/safe-identifier": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", "dev": true }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "devOptional": true, "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "devOptional": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", "engines": { "node": ">=10" } }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sax": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", "dev": true, "engines": { "node": ">=11.0.0" } }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "devOptional": true, "bin": { "semver": "bin/semver.js" }, "engines": { "node": ">=10" } }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" }, "engines": { "node": ">= 18" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/serialize-javascript": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true, "engines": { "node": ">=20.0.0" } }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" }, "engines": { "node": ">= 18" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "devOptional": true, "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/set-function-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "devOptional": true, "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/set-proto": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "devOptional": true, "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, "node_modules/sha.js": { "version": "2.4.12", "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", "optional": true, "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" }, "engines": { "node": ">= 0.10" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/sha.js/node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "optional": true }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, "engines": { "node": ">=8" } }, "node_modules/shebang-regex": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "engines": { "node": ">=8" } }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/side-channel-list": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/side-channel-map": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/side-channel-weakmap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "optional": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" } }, "node_modules/smob": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", "dev": true }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "optional": true, "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" }, "engines": { "node": ">= 10.0.0", "npm": ">= 3.0.0" } }, "node_modules/socks-proxy-agent": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "optional": true, "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" }, "engines": { "node": ">= 14" } }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", "license": "MIT", "optional": true, "dependencies": { "atomic-sleep": "^1.0.0" } }, "node_modules/sort-object-keys": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-2.1.0.tgz", "integrity": "sha512-SOiEnthkJKPv2L6ec6HMwhUcN0/lppkeYuN1x63PbyPRrgSPIuBJCiYxYyvWRTtjMlOi14vQUCGUJqS6PLVm8g==", "dev": true }, "node_modules/sort-package-json": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/sort-package-json/-/sort-package-json-3.6.0.tgz", "integrity": "sha512-fyJsPLhWvY7u2KsKPZn1PixbXp+1m7V8NWqU8CvgFRbMEX41Ffw1kD8n0CfJiGoaSfoAvbrqRRl/DcHO8omQOQ==", "dev": true, "dependencies": { "detect-indent": "^7.0.2", "detect-newline": "^4.0.1", "git-hooks-list": "^4.1.1", "is-plain-obj": "^4.1.0", "semver": "^7.7.3", "sort-object-keys": "^2.0.1", "tinyglobby": "^0.2.15" }, "bin": { "sort-package-json": "cli.js" }, "engines": { "node": ">=20" } }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "engines": { "node": ">=0.10.0" } }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", "optional": true, "dependencies": { "memory-pager": "^1.0.2" } }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "optional": true, "engines": { "node": ">= 10.x" } }, "node_modules/sqlstring": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.1.tgz", "integrity": "sha512-ooAzh/7dxIG5+uDik1z/Rd1vli0+38izZhGzSa34FwR7IbelPWCCKSNIl8jlL/F7ERvy8CB2jNeM1E9i9mXMAQ==", "optional": true, "engines": { "node": ">= 0.6" } }, "node_modules/ssri": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", "optional": true, "dependencies": { "minipass": "^7.0.3" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", "dev": true }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", "engines": { "node": "*" } }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "engines": { "node": ">= 0.8" } }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "devOptional": true, "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", "engines": { "node": ">=10.0.0" } }, "node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", "dev": true }, "node_modules/strict-event-emitter-types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==" }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dependencies": { "safe-buffer": "~5.2.0" } }, "node_modules/string_decoder/node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ] }, "node_modules/string-hash": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", "integrity": "sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==", "dev": true }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" }, "engines": { "node": ">=8" } }, "node_modules/string.prototype.trim": { "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "devOptional": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/string.prototype.trimend": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "devOptional": true, "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/string.prototype.trimstart": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "devOptional": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, "engines": { "node": ">=8" } }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, "engines": { "node": ">=4" } }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "engines": { "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strnum": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], "license": "MIT", "optional": true }, "node_modules/style-inject": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/style-inject/-/style-inject-0.3.0.tgz", "integrity": "sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==", "dev": true }, "node_modules/stylehacks": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", "integrity": "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==", "dev": true, "dependencies": { "browserslist": "^4.21.4", "postcss-selector-parser": "^6.0.4" }, "engines": { "node": "^10 || ^12 || >=14.0" }, "peerDependencies": { "postcss": "^8.2.15" } }, "node_modules/superagent": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", "dev": true, "dependencies": { "component-emitter": "^1.3.1", "cookiejar": "^2.1.4", "debug": "^4.3.7", "fast-safe-stringify": "^2.1.1", "form-data": "^4.0.5", "formidable": "^3.5.4", "methods": "^1.1.2", "mime": "2.6.0", "qs": "^6.14.1" }, "engines": { "node": ">=14.18.0" } }, "node_modules/supertest": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", "dev": true, "dependencies": { "cookie-signature": "^1.2.2", "methods": "^1.1.2", "superagent": "^10.3.0" }, "engines": { "node": ">=14.18.0" } }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "dependencies": { "has-flag": "^4.0.0" }, "engines": { "node": ">=8" } }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "devOptional": true, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/svgo": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.2.tgz", "integrity": "sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==", "dev": true, "dependencies": { "commander": "^7.2.0", "css-select": "^4.1.3", "css-tree": "^1.1.3", "csso": "^4.2.0", "picocolors": "^1.0.0", "sax": "^1.5.0", "stable": "^0.1.8" }, "bin": { "svgo": "bin/svgo" }, "engines": { "node": ">=10.13.0" } }, "node_modules/svgo/node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "dev": true, "engines": { "node": ">= 10" } }, "node_modules/synckit": { "version": "0.11.12", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "dependencies": { "@pkgr/core": "^0.2.9" }, "engines": { "node": "^14.18.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/synckit" } }, "node_modules/tar": { "version": "7.5.16", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", "license": "BlueOak-1.0.0", "optional": true, "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" }, "engines": { "node": ">=18" } }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "optional": true, "engines": { "node": ">=18" } }, "node_modules/tarn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==", "optional": true, "engines": { "node": ">=8.0.0" } }, "node_modules/terser": { "version": "5.46.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" }, "engines": { "node": ">=10" } }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", "license": "MIT", "optional": true, "dependencies": { "real-require": "^0.2.0" } }, "node_modules/tildify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==", "optional": true, "engines": { "node": ">=8" } }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "devOptional": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" }, "funding": { "url": "https://github.com/sponsors/SuperchupuDev" } }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", "optional": true, "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" }, "engines": { "node": ">= 0.4" } }, "node_modules/to-buffer/node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "optional": true }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "dependencies": { "is-number": "^7.0.0" }, "engines": { "node": ">=8.0" } }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "engines": { "node": ">=0.6" } }, "node_modules/tr46": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dependencies": { "punycode": "^2.3.1" }, "engines": { "node": ">=20" } }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", "engines": { "node": ">= 14.0.0" } }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "engines": { "node": ">=18.12" }, "peerDependencies": { "typescript": ">=4.8.4" } }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "devOptional": true }, "node_modules/tsx": { "version": "4.22.4", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" }, "engines": { "node": ">=18.0.0" }, "optionalDependencies": { "fsevents": "~2.3.3" } }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "dependencies": { "prelude-ls": "^1.2.1" }, "engines": { "node": ">= 0.8.0" } }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { "node": ">= 0.6" } }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "devOptional": true, "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" } }, "node_modules/typed-array-byte-length": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "devOptional": true, "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/typed-array-byte-offset": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "devOptional": true, "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/typed-array-length": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "devOptional": true, "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { "node": ">=14.17" } }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "devOptional": true, "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "devOptional": true }, "node_modules/unique-filename": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz", "integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==", "optional": true, "dependencies": { "unique-slug": "^6.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/unique-slug": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz", "integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==", "optional": true, "dependencies": { "imurmurhash": "^0.1.4" }, "engines": { "node": "^20.17.0 || >=22.9.0" } }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "engines": { "node": ">= 0.8" } }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { "type": "opencollective", "url": "https://opencollective.com/browserslist" }, { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/browserslist" }, { "type": "github", "url": "https://github.com/sponsors/ai" } ], "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" }, "peerDependencies": { "browserslist": ">= 4.21.0" } }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "dependencies": { "punycode": "^2.1.0" } }, "node_modules/url-join": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", "optional": true }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", "license": "MIT", "optional": true, "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/util-promisify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/util-promisify/-/util-promisify-3.0.0.tgz", "integrity": "sha512-uWRZJMjSWt/A1J1exfqz7xiKx2kVpAHR5qIDr6WwwBMQHDoKbo2I1kQN62iA2uXHxOSVpZRDvbm8do+4ijfkNA==", "optional": true, "dependencies": { "object.getownpropertydescriptors": "^2.0.3" } }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "engines": { "node": ">= 0.8" } }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", "engines": { "node": ">= 8" } }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "engines": { "node": ">=20" } }, "node_modules/whatwg-url": { "version": "15.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" }, "engines": { "node": ">=20" } }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" }, "engines": { "node": ">= 8" } }, "node_modules/which-boxed-primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "devOptional": true, "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/which-builtin-type": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "devOptional": true, "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/which-collection": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "devOptional": true, "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "devOptional": true, "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/winston": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" }, "engines": { "node": ">= 12.0.0" } }, "node_modules/winston-transport": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" }, "engines": { "node": ">= 12.0.0" } }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "engines": { "node": ">=0.10.0" } }, "node_modules/workerpool": { "version": "9.3.4", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", "dev": true }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" }, "engines": { "node": ">=10" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/xml-naming": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], "license": "MIT", "optional": true, "engines": { "node": ">=16.0.0" } }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "optional": true, "engines": { "node": ">=0.4" } }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, "engines": { "node": ">=10" } }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "optional": true }, "node_modules/yaml": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "engines": { "node": ">= 6" } }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" }, "engines": { "node": ">=12" } }, "node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, "engines": { "node": ">=12" } }, "node_modules/yargs-unparser": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", "dev": true, "dependencies": { "camelcase": "^6.0.0", "decamelize": "^4.0.0", "flat": "^5.0.2", "is-plain-obj": "^2.1.0" }, "engines": { "node": ">=10" } }, "node_modules/yargs-unparser/node_modules/is-plain-obj": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "dev": true, "engines": { "node": ">=8" } }, "node_modules/yocto-queue": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", "engines": { "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } } } } linagora-ldap-rest-16e557e/package.json000066400000000000000000000637511522642357000200520ustar00rootroot00000000000000{ "name": "ldap-rest", "version": "0.4.6", "description": "Light directory manager", "keywords": [ "Identity-Manager" ], "homepage": "https://github.com/linagora/ldap-rest#readme", "bugs": { "url": "https://github.com/linagora/ldap-rest/issues" }, "repository": { "type": "git", "url": "git+https://github.com/linagora/ldap-rest.git" }, "license": "AGPL-3.0", "author": "Xavier Guimard ", "type": "module", "exports": { ".": { "types": "./dist/src/bin/index.d.ts", "import": "./dist/bin/index.js" }, "./hooks": { "types": "./dist/src/hooks.d.ts" }, "./plugin": { "types": "./dist/src/abstract/plugin.d.ts", "import": "./dist/abstract/plugin.js" }, "./expressformatedresponses": { "types": "./dist/src/lib/expressFormatedResponses.d.ts", "import": "./dist/lib/expressFormatedResponses.js" }, "./ldapactions": { "types": "./dist/src/lib/ldapActions.d.ts", "import": "./dist/lib/ldapActions.js" }, "./plugin-auth-authzdynamic": { "types": "./dist/src/plugins/auth/authzDynamic.d.ts", "import": "./dist/plugins/auth/authzDynamic.js" }, "./plugin-auth-authzdynamichash": { "types": "./dist/src/plugins/auth/authzDynamicHash.d.ts", "import": "./dist/plugins/auth/authzDynamicHash.js" }, "./plugin-auth-authzlinid1": { "types": "./dist/src/plugins/auth/authzLinid1.d.ts", "import": "./dist/plugins/auth/authzLinid1.js" }, "./plugin-auth-authzperbranch": { "types": "./dist/src/plugins/auth/authzPerBranch.d.ts", "import": "./dist/plugins/auth/authzPerBranch.js" }, "./plugin-auth-authzperroute": { "types": "./dist/src/plugins/auth/authzPerRoute.d.ts", "import": "./dist/plugins/auth/authzPerRoute.js" }, "./plugin-auth-crowdsec": { "types": "./dist/src/plugins/auth/crowdsec.d.ts", "import": "./dist/plugins/auth/crowdsec.js" }, "./plugin-auth-hmac": { "types": "./dist/src/plugins/auth/hmac.d.ts", "import": "./dist/plugins/auth/hmac.js" }, "./plugin-auth-llng": { "types": "./dist/src/plugins/auth/llng.d.ts", "import": "./dist/plugins/auth/llng.js" }, "./plugin-auth-openidconnect": { "types": "./dist/src/plugins/auth/openidconnect.d.ts", "import": "./dist/plugins/auth/openidconnect.js" }, "./plugin-auth-ratelimit": { "types": "./dist/src/plugins/auth/rateLimit.d.ts", "import": "./dist/plugins/auth/rateLimit.js" }, "./plugin-auth-token": { "types": "./dist/src/plugins/auth/token.d.ts", "import": "./dist/plugins/auth/token.js" }, "./plugin-auth-totp": { "types": "./dist/src/plugins/auth/totp.d.ts", "import": "./dist/plugins/auth/totp.js" }, "./plugin-auth-trustedproxy": { "types": "./dist/src/plugins/auth/trustedProxy.d.ts", "import": "./dist/plugins/auth/trustedProxy.js" }, "./plugin-configapi": { "types": "./dist/src/plugins/configApi.d.ts", "import": "./dist/plugins/configApi.js" }, "./plugin-demo-helloworld": { "types": "./dist/src/plugins/demo/helloworld.d.ts", "import": "./dist/plugins/demo/helloworld.js" }, "./plugin-ldap-bulkimport": { "types": "./dist/src/plugins/ldap/bulkImport.d.ts", "import": "./dist/plugins/ldap/bulkImport.js" }, "./plugin-ldap-departmentsync": { "types": "./dist/src/plugins/ldap/departmentSync.d.ts", "import": "./dist/plugins/ldap/departmentSync.js" }, "./plugin-ldap-externalusersingroups": { "types": "./dist/src/plugins/ldap/externalUsersInGroups.d.ts", "import": "./dist/plugins/ldap/externalUsersInGroups.js" }, "./plugin-ldap-flatgeneric": { "types": "./dist/src/plugins/ldap/flatGeneric.d.ts", "import": "./dist/plugins/ldap/flatGeneric.js" }, "./plugin-ldap-groups": { "types": "./dist/src/plugins/ldap/groups.d.ts", "import": "./dist/plugins/ldap/groups.js" }, "./plugin-ldap-onchange": { "types": "./dist/src/plugins/ldap/onChange.d.ts", "import": "./dist/plugins/ldap/onChange.js" }, "./plugin-ldap-organization": { "types": "./dist/src/plugins/ldap/organization.d.ts", "import": "./dist/plugins/ldap/organization.js" }, "./plugin-ldap-organizations": { "types": "./dist/src/plugins/ldap/organizations.d.ts", "import": "./dist/plugins/ldap/organizations.js" }, "./plugin-ldap-passwordpolicy": { "types": "./dist/src/plugins/ldap/passwordPolicy.d.ts", "import": "./dist/plugins/ldap/passwordPolicy.js" }, "./plugin-ldap-trash": { "types": "./dist/src/plugins/ldap/trash.d.ts", "import": "./dist/plugins/ldap/trash.js" }, "./plugin-rabbitmq": { "types": "./dist/src/plugins/rabbitmq.d.ts", "import": "./dist/plugins/rabbitmq.js" }, "./plugin-scim-baseresolver": { "types": "./dist/src/plugins/scim/baseResolver.d.ts", "import": "./dist/plugins/scim/baseResolver.js" }, "./plugin-scim-bulk": { "types": "./dist/src/plugins/scim/bulk.d.ts", "import": "./dist/plugins/scim/bulk.js" }, "./plugin-scim-discovery": { "types": "./dist/src/plugins/scim/discovery.d.ts", "import": "./dist/plugins/scim/discovery.js" }, "./plugin-scim-errors": { "types": "./dist/src/plugins/scim/errors.d.ts", "import": "./dist/plugins/scim/errors.js" }, "./plugin-scim-filter": { "types": "./dist/src/plugins/scim/filter.d.ts", "import": "./dist/plugins/scim/filter.js" }, "./plugin-scim-groups": { "types": "./dist/src/plugins/scim/groups.d.ts", "import": "./dist/plugins/scim/groups.js" }, "./plugin-scim-mapping": { "types": "./dist/src/plugins/scim/mapping.d.ts", "import": "./dist/plugins/scim/mapping.js" }, "./plugin-scim-patch": { "types": "./dist/src/plugins/scim/patch.d.ts", "import": "./dist/plugins/scim/patch.js" }, "./plugin-scim-scim": { "types": "./dist/src/plugins/scim/scim.d.ts", "import": "./dist/plugins/scim/scim.js" }, "./plugin-scim-types": { "types": "./dist/src/plugins/scim/types.d.ts", "import": "./dist/plugins/scim/types.js" }, "./plugin-scim-users": { "types": "./dist/src/plugins/scim/users.d.ts", "import": "./dist/plugins/scim/users.js" }, "./plugin-static": { "types": "./dist/src/plugins/static.d.ts", "import": "./dist/plugins/static.js" }, "./plugin-twake-appaccountsapi": { "types": "./dist/src/plugins/twake/appAccountsApi.d.ts", "import": "./dist/plugins/twake/appAccountsApi.js" }, "./plugin-twake-appaccountsconsistency": { "types": "./dist/src/plugins/twake/appAccountsConsistency.d.ts", "import": "./dist/plugins/twake/appAccountsConsistency.js" }, "./plugin-twake-calendarresources": { "types": "./dist/src/plugins/twake/calendarResources.d.ts", "import": "./dist/plugins/twake/calendarResources.js" }, "./plugin-twake-clouderyprovision": { "types": "./dist/src/plugins/twake/clouderyProvision.d.ts", "import": "./dist/plugins/twake/clouderyProvision.js" }, "./plugin-twake-cozyprovision": { "types": "./dist/src/plugins/twake/cozyProvision.d.ts", "import": "./dist/plugins/twake/cozyProvision.js" }, "./plugin-twake-drive": { "types": "./dist/src/plugins/twake/drive.d.ts", "import": "./dist/plugins/twake/drive.js" }, "./plugin-twake-james": { "types": "./dist/src/plugins/twake/james.d.ts", "import": "./dist/plugins/twake/james.js" }, "./plugin-weblogs": { "types": "./dist/src/plugins/weblogs.d.ts", "import": "./dist/plugins/weblogs.js" }, "./abstract-ldapflat": { "types": "./dist/src/abstract/ldapFlat.d.ts", "import": "./dist/abstract/ldapFlat.js" }, "./abstract-plugin": { "types": "./dist/src/abstract/plugin.d.ts", "import": "./dist/abstract/plugin.js" }, "./abstract-twakeplugin": { "types": "./dist/src/abstract/twakePlugin.d.ts", "import": "./dist/abstract/twakePlugin.js" }, "./browser-ldap-group-editor-ldapgroupeditor": { "types": "./dist/src/browser/ldap-group-editor/LdapGroupEditor.d.ts", "import": "./static/browser/ldap-group-editor/LdapGroupEditor.js" }, "./browser-ldap-group-editor-api-groupapiclient": { "types": "./dist/src/browser/ldap-group-editor/api/GroupApiClient.d.ts", "import": "./static/browser/ldap-group-editor/api/GroupApiClient.js" }, "./browser-ldap-group-editor-components-creategroupmodal": { "types": "./dist/src/browser/ldap-group-editor/components/CreateGroupModal.d.ts", "import": "./static/browser/ldap-group-editor/components/CreateGroupModal.js" }, "./browser-ldap-group-editor-components-grouppropertyeditor": { "types": "./dist/src/browser/ldap-group-editor/components/GroupPropertyEditor.d.ts", "import": "./static/browser/ldap-group-editor/components/GroupPropertyEditor.js" }, "./browser-ldap-group-editor-components-grouptree": { "types": "./dist/src/browser/ldap-group-editor/components/GroupTree.d.ts", "import": "./static/browser/ldap-group-editor/components/GroupTree.js" }, "./browser-ldap-group-editor-components-movegroupmodal": { "types": "./dist/src/browser/ldap-group-editor/components/MoveGroupModal.d.ts", "import": "./static/browser/ldap-group-editor/components/MoveGroupModal.js" }, "./browser-ldap-group-editor-index": { "types": "./dist/src/browser/ldap-group-editor/index.d.ts", "import": "./static/browser/ldap-group-editor/index.js" }, "./browser-ldap-group-editor-types": { "types": "./dist/src/browser/ldap-group-editor/types.d.ts", "import": "./static/browser/ldap-group-editor/types.js" }, "./browser-ldap-resource-editor-api-resourceapiclient": { "types": "./dist/src/browser/ldap-resource-editor/api/ResourceApiClient.d.ts", "import": "./static/browser/ldap-resource-editor/api/ResourceApiClient.js" }, "./browser-ldap-resource-editor-cache-cachemanager": { "types": "./dist/src/browser/ldap-resource-editor/cache/CacheManager.d.ts", "import": "./static/browser/ldap-resource-editor/cache/CacheManager.js" }, "./browser-ldap-resource-editor-index": { "types": "./dist/src/browser/ldap-resource-editor/index.d.ts", "import": "./static/browser/ldap-resource-editor/index.js" }, "./browser-ldap-resource-editor-types": { "types": "./dist/src/browser/ldap-resource-editor/types.d.ts", "import": "./static/browser/ldap-resource-editor/types.js" }, "./browser-ldap-tree-viewer-ldaptreeviewer": { "types": "./dist/src/browser/ldap-tree-viewer/LdapTreeViewer.d.ts", "import": "./static/browser/ldap-tree-viewer/LdapTreeViewer.js" }, "./browser-ldap-tree-viewer-api-ldapapiclient": { "types": "./dist/src/browser/ldap-tree-viewer/api/LdapApiClient.d.ts", "import": "./static/browser/ldap-tree-viewer/api/LdapApiClient.js" }, "./browser-ldap-tree-viewer-components-treenode": { "types": "./dist/src/browser/ldap-tree-viewer/components/TreeNode.d.ts", "import": "./static/browser/ldap-tree-viewer/components/TreeNode.js" }, "./browser-ldap-tree-viewer-components-treeroot": { "types": "./dist/src/browser/ldap-tree-viewer/components/TreeRoot.d.ts", "import": "./static/browser/ldap-tree-viewer/components/TreeRoot.js" }, "./browser-ldap-tree-viewer-index": { "types": "./dist/src/browser/ldap-tree-viewer/index.d.ts", "import": "./static/browser/ldap-tree-viewer/index.js" }, "./browser-ldap-tree-viewer-store-store": { "types": "./dist/src/browser/ldap-tree-viewer/store/Store.d.ts", "import": "./static/browser/ldap-tree-viewer/store/Store.js" }, "./browser-ldap-tree-viewer-store-actions": { "types": "./dist/src/browser/ldap-tree-viewer/store/actions.d.ts", "import": "./static/browser/ldap-tree-viewer/store/actions.js" }, "./browser-ldap-tree-viewer-store-reducers": { "types": "./dist/src/browser/ldap-tree-viewer/store/reducers.d.ts", "import": "./static/browser/ldap-tree-viewer/store/reducers.js" }, "./browser-ldap-tree-viewer-types": { "types": "./dist/src/browser/ldap-tree-viewer/types.d.ts", "import": "./static/browser/ldap-tree-viewer/types.js" }, "./browser-ldap-unit-editor-ldapuniteditor": { "types": "./dist/src/browser/ldap-unit-editor/LdapUnitEditor.d.ts", "import": "./static/browser/ldap-unit-editor/LdapUnitEditor.js" }, "./browser-ldap-unit-editor-api-unitapiclient": { "types": "./dist/src/browser/ldap-unit-editor/api/UnitApiClient.d.ts", "import": "./static/browser/ldap-unit-editor/api/UnitApiClient.js" }, "./browser-ldap-unit-editor-components-moveunitmodal": { "types": "./dist/src/browser/ldap-unit-editor/components/MoveUnitModal.d.ts", "import": "./static/browser/ldap-unit-editor/components/MoveUnitModal.js" }, "./browser-ldap-unit-editor-components-unitpropertyeditor": { "types": "./dist/src/browser/ldap-unit-editor/components/UnitPropertyEditor.d.ts", "import": "./static/browser/ldap-unit-editor/components/UnitPropertyEditor.js" }, "./browser-ldap-unit-editor-components-unittree": { "types": "./dist/src/browser/ldap-unit-editor/components/UnitTree.d.ts", "import": "./static/browser/ldap-unit-editor/components/UnitTree.js" }, "./browser-ldap-unit-editor-index": { "types": "./dist/src/browser/ldap-unit-editor/index.d.ts", "import": "./static/browser/ldap-unit-editor/index.js" }, "./browser-ldap-unit-editor-types": { "types": "./dist/src/browser/ldap-unit-editor/types.d.ts", "import": "./static/browser/ldap-unit-editor/types.js" }, "./browser-ldap-user-editor-ldapusereditor": { "types": "./dist/src/browser/ldap-user-editor/LdapUserEditor.d.ts", "import": "./static/browser/ldap-user-editor/LdapUserEditor.js" }, "./browser-ldap-user-editor-api-userapiclient": { "types": "./dist/src/browser/ldap-user-editor/api/UserApiClient.d.ts", "import": "./static/browser/ldap-user-editor/api/UserApiClient.js" }, "./browser-ldap-user-editor-cache-cachemanager": { "types": "./dist/src/browser/ldap-user-editor/cache/CacheManager.d.ts", "import": "./static/browser/ldap-user-editor/cache/CacheManager.js" }, "./browser-ldap-user-editor-components-moveusermodal": { "types": "./dist/src/browser/ldap-user-editor/components/MoveUserModal.d.ts", "import": "./static/browser/ldap-user-editor/components/MoveUserModal.js" }, "./browser-ldap-user-editor-components-pointerfield": { "types": "./dist/src/browser/ldap-user-editor/components/PointerField.d.ts", "import": "./static/browser/ldap-user-editor/components/PointerField.js" }, "./browser-ldap-user-editor-components-usereditor": { "types": "./dist/src/browser/ldap-user-editor/components/UserEditor.d.ts", "import": "./static/browser/ldap-user-editor/components/UserEditor.js" }, "./browser-ldap-user-editor-components-userlist": { "types": "./dist/src/browser/ldap-user-editor/components/UserList.d.ts", "import": "./static/browser/ldap-user-editor/components/UserList.js" }, "./browser-ldap-user-editor-components-usertree": { "types": "./dist/src/browser/ldap-user-editor/components/UserTree.d.ts", "import": "./static/browser/ldap-user-editor/components/UserTree.js" }, "./browser-ldap-user-editor-index": { "types": "./dist/src/browser/ldap-user-editor/index.d.ts", "import": "./static/browser/ldap-user-editor/index.js" }, "./browser-ldap-user-editor-types": { "types": "./dist/src/browser/ldap-user-editor/types.d.ts", "import": "./static/browser/ldap-user-editor/types.js" }, "./browser-shared-components-disposablecomponent": { "types": "./dist/src/browser/shared/components/DisposableComponent.d.ts", "import": "./static/browser/shared/components/DisposableComponent.js" }, "./browser-shared-components-modal": { "types": "./dist/src/browser/shared/components/Modal.d.ts", "import": "./static/browser/shared/components/Modal.js" }, "./browser-shared-components-statusmessage": { "types": "./dist/src/browser/shared/components/StatusMessage.d.ts", "import": "./static/browser/shared/components/StatusMessage.js" }, "./browser-shared-index": { "types": "./dist/src/browser/shared/index.d.ts", "import": "./static/browser/shared/index.js" }, "./browser-shared-types-index": { "types": "./dist/src/browser/shared/types/index.d.ts", "import": "./static/browser/shared/types/index.js" }, "./browser-shared-utils-dom": { "types": "./dist/src/browser/shared/utils/dom.d.ts", "import": "./static/browser/shared/utils/dom.js" }, "./browser-shared-utils-form": { "types": "./dist/src/browser/shared/utils/form.d.ts", "import": "./static/browser/shared/utils/form.js" }, "./browser-shared-utils-hmac": { "types": "./dist/src/browser/shared/utils/hmac.d.ts", "import": "./static/browser/shared/utils/hmac.js" }, "./browser-shared-utils-schema": { "types": "./dist/src/browser/shared/utils/schema.d.ts", "import": "./static/browser/shared/utils/schema.js" }, "./browser-shared-utils-totp": { "types": "./dist/src/browser/shared/utils/totp.d.ts", "import": "./static/browser/shared/utils/totp.js" }, "./schema-ad-computers": { "import": "./static/schemas/ad/computers.json", "require": "./static/schemas/ad/computers.json" }, "./schema-ad-groups": { "import": "./static/schemas/ad/groups.json", "require": "./static/schemas/ad/groups.json" }, "./schema-ad-organizations": { "import": "./static/schemas/ad/organizations.json", "require": "./static/schemas/ad/organizations.json" }, "./schema-ad-users": { "import": "./static/schemas/ad/users.json", "require": "./static/schemas/ad/users.json" }, "./schema-obm-organizations": { "import": "./static/schemas/obm/organizations.json", "require": "./static/schemas/obm/organizations.json" }, "./schema-obm-users": { "import": "./static/schemas/obm/users.json", "require": "./static/schemas/obm/users.json" }, "./schema-scim-group": { "import": "./static/schemas/scim/Group.json", "require": "./static/schemas/scim/Group.json" }, "./schema-scim-user": { "import": "./static/schemas/scim/User.json", "require": "./static/schemas/scim/User.json" }, "./schema-scim-default-mapping": { "import": "./static/schemas/scim/default-mapping.json", "require": "./static/schemas/scim/default-mapping.json" }, "./schema-standard-automountmaps": { "import": "./static/schemas/standard/automountMaps.json", "require": "./static/schemas/standard/automountMaps.json" }, "./schema-standard-devices": { "import": "./static/schemas/standard/devices.json", "require": "./static/schemas/standard/devices.json" }, "./schema-standard-dhcphosts": { "import": "./static/schemas/standard/dhcpHosts.json", "require": "./static/schemas/standard/dhcpHosts.json" }, "./schema-standard-dnsrecords": { "import": "./static/schemas/standard/dnsRecords.json", "require": "./static/schemas/standard/dnsRecords.json" }, "./schema-standard-groups": { "import": "./static/schemas/standard/groups.json", "require": "./static/schemas/standard/groups.json" }, "./schema-standard-netgroups": { "import": "./static/schemas/standard/netgroups.json", "require": "./static/schemas/standard/netgroups.json" }, "./schema-standard-organizations": { "import": "./static/schemas/standard/organizations.json", "require": "./static/schemas/standard/organizations.json" }, "./schema-standard-posixaccounts": { "import": "./static/schemas/standard/posixAccounts.json", "require": "./static/schemas/standard/posixAccounts.json" }, "./schema-standard-posixgroups": { "import": "./static/schemas/standard/posixGroups.json", "require": "./static/schemas/standard/posixGroups.json" }, "./schema-standard-sshpublickeys": { "import": "./static/schemas/standard/sshPublicKeys.json", "require": "./static/schemas/standard/sshPublicKeys.json" }, "./schema-standard-sudorules": { "import": "./static/schemas/standard/sudoRules.json", "require": "./static/schemas/standard/sudoRules.json" }, "./schema-standard-users": { "import": "./static/schemas/standard/users.json", "require": "./static/schemas/standard/users.json" }, "./schema-twake-devices": { "import": "./static/schemas/twake/devices.json", "require": "./static/schemas/twake/devices.json" }, "./schema-twake-groups": { "import": "./static/schemas/twake/groups.json", "require": "./static/schemas/twake/groups.json" }, "./schema-twake-nomenclature-twakeaccountstatus": { "import": "./static/schemas/twake/nomenclature/twakeAccountStatus.json", "require": "./static/schemas/twake/nomenclature/twakeAccountStatus.json" }, "./schema-twake-nomenclature-twakedeliverymode": { "import": "./static/schemas/twake/nomenclature/twakeDeliveryMode.json", "require": "./static/schemas/twake/nomenclature/twakeDeliveryMode.json" }, "./schema-twake-nomenclature-twakelisttype": { "import": "./static/schemas/twake/nomenclature/twakeListType.json", "require": "./static/schemas/twake/nomenclature/twakeListType.json" }, "./schema-twake-nomenclature-twakemailboxtype": { "import": "./static/schemas/twake/nomenclature/twakeMailboxType.json", "require": "./static/schemas/twake/nomenclature/twakeMailboxType.json" }, "./schema-twake-nomenclature-twaketitle": { "import": "./static/schemas/twake/nomenclature/twakeTitle.json", "require": "./static/schemas/twake/nomenclature/twakeTitle.json" }, "./schema-twake-organizations": { "import": "./static/schemas/twake/organizations.json", "require": "./static/schemas/twake/organizations.json" }, "./schema-twake-positions": { "import": "./static/schemas/twake/positions.json", "require": "./static/schemas/twake/positions.json" }, "./schema-twake-users": { "import": "./static/schemas/twake/users.json", "require": "./static/schemas/twake/users.json" } }, "main": "dist/bin/index.js", "types": "./dist/src/bin/index.d.ts", "bin": { "cleanup-external-users": "bin/cleanup-external-users.mjs", "ldap-rest": "bin/index.mjs", "sync-app-accounts": "bin/sync-app-accounts.mjs", "sync-james": "bin/sync-james.mjs" }, "files": [ "bin", "dist", "static", "docs", "examples", "LICENSE", "package.json" ], "scripts": { "build": "npm run build:prod", "build:browser": "rollup -c rollup.browser.config.mjs", "build:dev": "./.dev.mk builddev", "build:docker": "./.dev.mk builddocker", "build:dockerfile": "./.dev.mk Dockerfile", "build:pages": "tsx scripts/build-pages.ts", "build:prod": "npm run clean && NODE_ENV=production rollup -c && npm run build:browser && node scripts/moveBrowserLibs.mjs && npm run build:dockerfile", "build:watch": "rollup -c --watch", "check": "npm run check:ts && npm run lint && npm run format:check", "check:ts": "tsc --noEmit", "clean": "rimraf dist/* static/browser/* coverage", "dev": "npm run start:dev", "fix": "npm run lint:fix && npm run format:fix", "format:check": "prettier --check .", "format:check-diff": "prettier --check $(git diff $(git rev-parse --abbrev-ref --symbolic-full-name @{u}) --name-status|grep -E '^M|A'|cut -f2)", "format:fix": "prettier --write .", "format:fix-diff": "prettier --write $(git checkout|grep -E '^(M|A)'|cut -f2)", "generate:openapi": "tsx scripts/generate-openapi.ts", "lint": "eslint \"src/**/*.{ts,tsx,js,jsx}\"", "lint:fix": "eslint \"src/**/*.{ts,tsx,js,jsx}\" --fix", "start:dev": "./.dev.mk start", "test": "npm run check:ts && mocha", "test:dev": "./.dev.mk test", "test:one": "mocha --config .mocharc-one.json" }, "overrides": { "diff": ">=8.0.3", "fast-xml-parser": ">=5.3.6", "glob": ">=11.0.4", "minimatch": "^10.2.1", "serialize-javascript": ">=7.0.3" }, "dependencies": { "body-parser": "^2.2.0", "csv-parse": "^6.1.0", "express": "^5.1.0", "express-rate-limit": "^8.1.0", "ldapts": "^8.0.9", "lru-cache": "^11.2.2", "multer": "^2.0.2", "node-fetch": "^3.3.2", "p-limit": "^7.1.1", "winston": "^3.17.0" }, "devDependencies": { "@rollup/plugin-commonjs": "^28.0.6", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.4", "@types/chai": "^5.2.2", "@types/express": "^5.0.3", "@types/js-yaml": "^4.0.9", "@types/mocha": "^10.0.10", "@types/multer": "^2.0.0", "@types/node": "^24.5.1", "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^8.44.0", "@typescript-eslint/parser": "^8.44.0", "chai": "^6.0.1", "eslint": "^9.35.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^5.5.4", "fast-glob": "^3.3.3", "js-yaml": "^4.1.1", "mocha": "^11.7.2", "nock": "^14.0.10", "prettier": "^3.6.2", "rimraf": "^6.0.1", "rollup": "^4.50.2", "rollup-plugin-postcss": "^4.0.2", "sort-package-json": "^3.4.0", "supertest": "^7.1.4", "tslib": "^2.8.1", "tsx": "^4.20.5", "typescript": "^5.9.2" }, "optionalDependencies": { "@linagora/rabbitmq-client": "^0.1.2", "express-openid-connect": "^2.19.2", "lemonldap-ng-handler": "^0.7.3", "re2": "^1.22.1" } }linagora-ldap-rest-16e557e/rollup.browser.config.mjs000066400000000000000000000155111522642357000225310ustar00rootroot00000000000000import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import typescript from '@rollup/plugin-typescript'; import terser from '@rollup/plugin-terser'; import postcss from 'rollup-plugin-postcss'; import fg from 'fast-glob'; async function getBrowserLibraries() { return ( await fg('src/browser/**/*.ts', { ignore: ['**/*.test.ts', '**/*.css'], }) ).sort(); } export default async () => { const browserFiles = await getBrowserLibraries(); return [ // Individual modules for npm imports { input: browserFiles, output: { dir: 'dist', format: 'es', sourcemap: true, preserveModules: true, preserveModulesRoot: 'src', }, plugins: [ resolve({ browser: true, }), commonjs(), postcss({ extract: false, inject: false, modules: false, }), typescript({ tsconfig: './tsconfig.browser.json', compilerOptions: { outDir: 'dist', declarationDir: 'dist', declaration: true, }, declarationMap: false, sourceMap: true, }), ], }, // UMD/ESM bundles for shared utilities { input: 'src/browser/shared/index.ts', output: [ { file: 'static/browser/shared.js', format: 'umd', name: 'MiniDmShared', sourcemap: true, exports: 'named', }, { file: 'static/browser/shared.esm.js', format: 'esm', sourcemap: true, }, { file: 'static/browser/shared.min.js', format: 'umd', name: 'MiniDmShared', sourcemap: true, exports: 'named', plugins: [terser()], }, ], plugins: [ resolve({ browser: true, }), commonjs(), typescript({ tsconfig: './tsconfig.browser.json', compilerOptions: { declaration: false, declarationDir: undefined, }, sourceMap: true, }), ], }, // UMD/ESM bundles for ldap-tree-viewer { input: 'src/browser/ldap-tree-viewer/index.ts', output: [ { file: 'static/browser/ldap-tree-viewer.js', format: 'umd', name: 'LdapTreeViewer', sourcemap: true, exports: 'named', }, { file: 'static/browser/ldap-tree-viewer.esm.js', format: 'esm', sourcemap: true, }, { file: 'static/browser/ldap-tree-viewer.min.js', format: 'umd', name: 'LdapTreeViewer', sourcemap: true, exports: 'named', plugins: [terser()], }, ], plugins: [ resolve({ browser: true, }), commonjs(), typescript({ tsconfig: './tsconfig.browser.json', compilerOptions: { declaration: false, // No types for bundles declarationDir: undefined, // Override tsconfig.browser.json }, sourceMap: true, }), postcss({ extract: 'ldap-tree-viewer.css', minimize: true, sourceMap: true, }), ], }, // UMD/ESM bundles for ldap-user-editor { input: 'src/browser/ldap-user-editor/index.ts', output: [ { file: 'static/browser/ldap-user-editor.js', format: 'umd', name: 'LdapUserEditor', sourcemap: true, exports: 'named', }, { file: 'static/browser/ldap-user-editor.esm.js', format: 'esm', sourcemap: true, }, { file: 'static/browser/ldap-user-editor.min.js', format: 'umd', name: 'LdapUserEditor', sourcemap: true, exports: 'named', plugins: [terser()], }, ], plugins: [ resolve({ browser: true, }), commonjs(), typescript({ tsconfig: './tsconfig.browser.json', compilerOptions: { declaration: false, // No types for bundles declarationDir: undefined, // Override tsconfig.browser.json }, sourceMap: true, }), postcss({ extract: 'ldap-user-editor.css', minimize: true, sourceMap: true, }), ], }, // UMD/ESM bundles for ldap-group-editor { input: 'src/browser/ldap-group-editor/index.ts', output: [ { file: 'static/browser/ldap-group-editor.js', format: 'umd', name: 'LdapGroupEditor', sourcemap: true, exports: 'named', }, { file: 'static/browser/ldap-group-editor.esm.js', format: 'esm', sourcemap: true, }, { file: 'static/browser/ldap-group-editor.min.js', format: 'umd', name: 'LdapGroupEditor', sourcemap: true, exports: 'named', plugins: [terser()], }, ], plugins: [ resolve({ browser: true, }), commonjs(), typescript({ tsconfig: './tsconfig.browser.json', compilerOptions: { declaration: false, // No types for bundles declarationDir: undefined, // Override tsconfig.browser.json }, sourceMap: true, }), postcss({ extract: false, // Reuse ldap-user-editor.css minimize: false, sourceMap: false, }), ], }, // UMD/ESM bundles for ldap-unit-editor { input: 'src/browser/ldap-unit-editor/index.ts', output: [ { file: 'static/browser/ldap-unit-editor.js', format: 'umd', name: 'LdapUnitEditor', sourcemap: true, exports: 'named', }, { file: 'static/browser/ldap-unit-editor.esm.js', format: 'esm', sourcemap: true, }, { file: 'static/browser/ldap-unit-editor.min.js', format: 'umd', name: 'LdapUnitEditor', sourcemap: true, exports: 'named', plugins: [terser()], }, ], plugins: [ resolve({ browser: true, }), commonjs(), typescript({ tsconfig: './tsconfig.browser.json', compilerOptions: { declaration: false, // No types for bundles declarationDir: undefined, // Override tsconfig.browser.json }, sourceMap: true, }), postcss({ extract: false, // Reuse ldap-user-editor.css minimize: false, sourceMap: false, }), ], }, ]; }; linagora-ldap-rest-16e557e/rollup.config.mjs000066400000000000000000000110561522642357000210470ustar00rootroot00000000000000/* eslint-disable no-undef */ import fs from 'fs'; import { builtinModules } from 'module'; import commonjs from '@rollup/plugin-commonjs'; import json from '@rollup/plugin-json'; import terser from '@rollup/plugin-terser'; import typescript from '@rollup/plugin-typescript'; import { sortPackageJson } from 'sort-package-json'; import { writeFileSync } from 'fs'; import fg from 'fast-glob'; const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf-8')); const PLUGINS_SRC_DIR = 'src/plugins'; const commonPlugins = dir => [ commonjs(), json(), typescript({ tsconfig: './tsconfig.json', module: 'ESNext', declaration: true, declarationDir: dir, declarationMap: false, sourceMap: process.env.NODE_ENV !== 'production', include: ['src/**/*'], outDir: dir, }), process.env.NODE_ENV === 'production' && terser({ compress: { drop_console: false, drop_debugger: true, }, mangle: { keep_classnames: true, keep_fnames: true, }, format: { comments: false, }, }), ]; const externalPackages = [ // binary modules 're2', // declared dependencies ...(pkg.dependencies ? Object.keys(pkg.dependencies) : []), ...(pkg.optionalDependencies ? Object.keys(pkg.optionalDependencies) : []), ]; // Mark as external: all Node builtins (with or without the `node:` prefix and // their subpaths, e.g. `timers/promises`), plus declared dependencies and their // subpaths (e.g. `csv-parse/sync`). const external = id => { const bare = id.replace(/^node:/, ''); if ( builtinModules.includes(bare) || builtinModules.includes(bare.split('/')[0]) ) return true; return externalPackages.some(dep => id === dep || id.startsWith(`${dep}/`)); }; async function getPluginEntries() { return (await fg('src/plugins/**/*.ts')) .map(file => file.replace(/^src\/plugins\//, '')) .sort(); } async function getAbstractEntries() { return (await fg('src/abstract/**/*.ts')) .map(file => file.replace(/^src\/abstract\//, '')) .sort(); } async function getSpecs() { return (await fg('static/schemas/**/*.json')) .map(file => file.replace(/^static\/schemas\//, '')) .sort(); } async function getBrowserLibraries() { return ( await fg('src/browser/**/*.ts', { ignore: ['**/*.test.ts', '**/*.css'], }) ) .map(file => file.replace(/^src\/browser\//, '').replace(/\.ts$/, '')) .sort(); } pkg.exports = { '.': { import: './dist/bin/index.js', types: './dist/src/bin/index.d.ts', }, './hooks': { types: './dist/src/hooks.d.ts', }, './plugin': { import: './dist/abstract/plugin.js', types: './dist/src/abstract/plugin.d.ts', }, './expressformatedresponses': { import: './dist/lib/expressFormatedResponses.js', types: './dist/src/lib/expressFormatedResponses.d.ts', }, './ldapactions': { import: './dist/lib/ldapActions.js', types: './dist/src/lib/ldapActions.d.ts', }, }; export default async () => { const p = await getPluginEntries(); p.forEach(plugin => { const name = plugin.replace(/\.ts$/, ''); pkg.exports[`./plugin-${name.toLowerCase().replace(/\//g, '-')}`] = { import: `./dist/plugins/${name}.js`, types: `./dist/src/plugins/${name}.d.ts`, }; }); const a = await getAbstractEntries(); a.forEach(abstract => { const name = abstract.replace(/\.ts$/, ''); pkg.exports[`./abstract-${name.toLowerCase().replace(/\//g, '-')}`] = { import: `./dist/abstract/${name}.js`, types: `./dist/src/abstract/${name}.d.ts`, }; }); (await getBrowserLibraries()).forEach(browserLib => { const name = browserLib.toLowerCase().replace(/\//g, '-'); pkg.exports[`./browser-${name}`] = { import: `./static/browser/${browserLib}.js`, types: `./dist/src/browser/${browserLib}.d.ts`, }; }); (await getSpecs()).forEach(spec => { const name = spec.replace(/\.json$/, '').replace(/\//g, '-'); pkg.exports[`./schema-${name.toLowerCase()}`] = { import: `./static/schemas/${spec}`, require: `./static/schemas/${spec}`, }; }); writeFileSync('package.json', sortPackageJson(JSON.stringify(pkg, null, 2))); return [ { input: [ 'src/bin/index.ts', ...p.map(file => `${PLUGINS_SRC_DIR}/${file}`), ...a.map(file => `src/abstract/${file}`), ], output: { dir: 'dist', format: 'es', sourcemap: process.env.NODE_ENV !== 'production', banner: '', preserveModules: true, }, plugins: commonPlugins('dist'), external, }, ]; }; linagora-ldap-rest-16e557e/scripts/000077500000000000000000000000001522642357000172375ustar00rootroot00000000000000linagora-ldap-rest-16e557e/scripts/build-pages.ts000066400000000000000000000227071522642357000220130ustar00rootroot00000000000000#!/usr/bin/env tsx /** * Build the static site published to GitHub Pages. * * Layout: * _site/ * index.html Landing page (intro extracted from README.md) * openapi.json Copy of the generated OpenAPI spec * api/index.html Redoc viewer pointing at ../openapi.json * * Run after `npm run generate:openapi` so the spec is up to date. */ import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const root = path.join(__dirname, '..'); const out = path.join(root, '_site'); const REPO_URL = 'https://github.com/linagora/ldap-rest'; const SITE_TITLE = 'LDAP-Rest'; function rmrf(p: string): void { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); } function extractReadmeIntro(readme: string): string { // Keep everything from the first `# ` heading up to (excluding) the // `## Documentation` section, which would otherwise pull in a long list // of links pointing into the repo (which is fine on GitHub but noisy on // the landing page where we already link to the repo). const lines = readme.split('\n'); const startIdx = lines.findIndex(l => /^#\s+/.test(l)); if (startIdx < 0) return readme; let endIdx = lines.length; for (let i = startIdx + 1; i < lines.length; i++) { if (/^##\s+Documentation\b/i.test(lines[i])) { endIdx = i; break; } } return lines.slice(startIdx, endIdx).join('\n').trim(); } function htmlEscape(s: string): string { return s.replace(/&/g, '&').replace(//g, '>'); } function renderLanding(introMarkdown: string): string { // The intro is rendered client-side by marked.js (loaded from CDN) so we // don't need to add a markdown dependency just for the landing page. const escaped = htmlEscape(introMarkdown); return ` ${SITE_TITLE}

${SITE_TITLE}

RESTful LDAP management with a plugin-based architecture.

API reference

Browse every REST endpoint with Redoc — searchable, request/response schemas, no setup.

Documentation

Usage guides, configuration, plugin development and authentication setup on GitHub.

Source code

linagora/ldap-rest — issues, releases, and contribution guide.

Loading…

`; } function renderApiPage(): string { // Redoc renders the OpenAPI spec into a single static HTML page. No // backend needed. The spec lives one level up so we can keep // /openapi.json as a stable canonical URL. return ` ${SITE_TITLE} — API reference
← ${SITE_TITLE} · API reference · openapi.json · GitHub
`; } function main(): void { const openapiPath = path.join(root, 'openapi.json'); if (!fs.existsSync(openapiPath)) { throw new Error( 'openapi.json not found — run `npm run generate:openapi` first.' ); } const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf-8'); const intro = extractReadmeIntro(readme); rmrf(out); fs.mkdirSync(path.join(out, 'api'), { recursive: true }); fs.copyFileSync(openapiPath, path.join(out, 'openapi.json')); fs.writeFileSync(path.join(out, 'index.html'), renderLanding(intro), 'utf-8'); fs.writeFileSync( path.join(out, 'api', 'index.html'), renderApiPage(), 'utf-8' ); // GitHub Pages with Jekyll would otherwise ignore files starting with // an underscore. We don't have any here, but the marker is cheap and // future-proofs the directory. fs.writeFileSync(path.join(out, '.nojekyll'), '', 'utf-8'); console.log(`✅ Site built at ${out}`); console.log(` - index.html (landing, intro from README.md)`); console.log(` - api/index.html (Redoc → ../openapi.json)`); console.log(` - openapi.json`); } main(); linagora-ldap-rest-16e557e/scripts/buildDockerfile.ts000066400000000000000000000035201522642357000226760ustar00rootroot00000000000000import fs from 'fs'; import path, { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import configArgs from '../src/config/args'; const __dirname = dirname(fileURLToPath(import.meta.url)); const moduleDir = path.resolve(__dirname, '..'); const dockerFile = path.resolve(__dirname, '..', 'Dockerfile'); let content = `## This Dockerfile is auto-generated by scripts/buildDockerfile.ts FROM node:24-alpine as builder RUN apk update && apk upgrade 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 && rm -rf /app-build && npm cache clean --force ENV NODE_ENV=production`; for (const [arg, env, def, type] of configArgs) { if (env === 'DM_PLUGINS') { content += ` \\\n ${env}=core/static,core/helloworld`; } else if (type === 'array') { content += ` \\\n ${env}=${(def as string[]).join(',')}`; } else if (type === 'boolean') { const boolDef = def ? 'true' : 'false'; content += ` \\\n ${env}=${boolDef}`; } else if (type === 'json') { const jsonDef = JSON.stringify(def) .replace(/\\/g, '\\\\') .replace(/"/g, '\\"'); content += ` \\\n ${env}="${jsonDef}"`; } else { const envDef = ((typeof def !== 'string' ? def.toString() : def) as string) .replace(/\\/g, '\\\\') .replace(/"/g, '\\"') .replace(new RegExp(moduleDir, 'g'), '/app/node_modules/ldap-rest'); content += envDef.length > 0 ? ` \\\n ${env}="${envDef}"` : ` \\\n ${env}=`; } } content += ` USER node EXPOSE 8081 CMD ["npx", "ldap-rest"] `; fs.writeFileSync(dockerFile, content); linagora-ldap-rest-16e557e/scripts/fixTestSkips.sh000066400000000000000000000022641522642357000222370ustar00rootroot00000000000000#!/bin/bash # Script to remove manual LDAP env var checks from tests # These are no longer needed since the embedded LDAP server sets them automatically FILES=( "test/plugins/twake/calendarResources.test.ts" "test/plugins/twake/appAccountsConsistency.test.ts" "test/plugins/ldap/flatGenericTwakeUsers.test.ts" "test/plugins/ldap/flatGenericStandardUsers.test.ts" "test/plugins/ldap/getApis.test.ts" "test/plugins/ldap/trashIntegration.test.ts" "test/plugins/ldap/trash.test.ts" "test/plugins/ldap/fixedAttributes.test.ts" ) for file in "${FILES[@]}"; do if [ -f "$file" ]; then echo "Processing $file..." # Remove the manual skip check blocks (if/console.warn/return pattern) # This is a complex sed operation, so we'll use perl for better multiline handling perl -i -0pe 's/ \/\/ Skip all tests if[^}]+\}\n//gs' "$file" perl -i -0pe 's/ if \(\s*![^}]+console\.warn\([^)]+Skipping[^}]+this\.skip[^}]+return;\s*\}\s*\n//gs' "$file" echo " ✓ Processed $file" else echo " ⚠ File not found: $file" fi done echo "" echo "Done! Manual skip checks removed from ${#FILES[@]} files." echo "Tests will now use the embedded LDAP server automatically." linagora-ldap-rest-16e557e/scripts/generate-openapi.ts000066400000000000000000000724351522642357000230450ustar00rootroot00000000000000#!/usr/bin/env tsx /** * OpenAPI Generator for LDAP-Rest * * Generates OpenAPI 3.0 specification by analyzing TypeScript source code. * Extracts routes from plugin api() methods without requiring runtime execution. * * Usage: npm run generate:openapi */ import * as ts from 'typescript'; import * as fs from 'fs'; import * as path from 'path'; import * as yaml from 'js-yaml'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); interface OpenAPIRoute { method: 'get' | 'post' | 'put' | 'delete' | 'patch'; path: string; summary?: string; description?: string; parameters?: Array<{ name: string; in: 'path' | 'query' | 'header' | 'body'; required?: boolean; schema?: any; description?: string; }>; requestBody?: { required?: boolean; content: { [mediaType: string]: { schema: any; }; }; }; responses?: { [statusCode: string]: { description: string; content?: { [mediaType: string]: { schema: any; }; }; }; }; tags?: string[]; } interface OpenAPISpec { openapi: string; info: { title: string; version: string; description: string; }; servers: Array<{ url: string; description: string; variables?: { [key: string]: { default: string; enum?: string[]; description?: string; }; }; }>; paths: { [path: string]: { [method: string]: any; }; }; components?: { schemas?: { [name: string]: any; }; }; tags?: Array<{ name: string; description: string; }>; } class OpenAPIGenerator { private program: ts.Program; private checker: ts.TypeChecker; private routes: Map = new Map(); private components: Record = {}; private pluginTags: Map = new Map([ ['ldapGroups', 'Groups'], ['ldapOrganizations', 'Organizations'], ['ldapFlatGeneric', 'Entities'], ['ldapBulkImport', 'Bulk Import'], ['james', 'Apache James Integration'], ['calendarResources', 'Calendar Resources'], ['configApi', 'Configuration'], ['static', 'Static Files'], ['trash', 'Trash'], ['externalUsersInGroups', 'External Users'], ['scim', 'SCIM 2.0'], ['ldapPasswordPolicy', 'Password Policy'], ['appAccountsApi', 'App Accounts'], ['hello', 'Demo'], ['authzDynamic', 'Authorization (Dynamic)'], ['james', 'Apache James Integration'], // Abstract LdapFlat exposes the generic per-resource CRUD surface; // every concrete flat plugin (users, mailgroups, …) inherits from it. ['LdapFlat', 'Entities'], ]); // Recognized plugin base classes (anything ultimately deriving from // DmPlugin via these). Without this, plugins extending an intermediate // base (e.g. AuthBase, TwakePlugin) would be silently skipped. private readonly pluginBaseClasses = new Set([ 'DmPlugin', 'AuthBase', 'AuthzBase', 'TwakePlugin', ]); constructor(private rootDir: string) { // Create TypeScript program const configPath = ts.findConfigFile( rootDir, ts.sys.fileExists, 'tsconfig.json' ); if (!configPath) { throw new Error('tsconfig.json not found'); } const { config } = ts.readConfigFile(configPath, ts.sys.readFile); const { options, fileNames } = ts.parseJsonConfigFileContent( config, ts.sys, path.dirname(configPath) ); this.program = ts.createProgram(fileNames, options); this.checker = this.program.getTypeChecker(); } /** * Analyze all plugin files. Walks `src/plugins/` for concrete plugins * plus `src/abstract/` so abstract bases like LdapFlat (which carries * the generic per-resource CRUD routes inherited by every flat * plugin) are documented too. */ public analyze(): void { this.analyzeDirectory(path.join(this.rootDir, 'src', 'plugins')); this.analyzeDirectory(path.join(this.rootDir, 'src', 'abstract')); } private analyzeDirectory(dir: string): void { const files = fs.readdirSync(dir); for (const file of files) { const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { this.analyzeDirectory(fullPath); } else if (file.endsWith('.ts') && !file.endsWith('.test.ts')) { this.analyzeFile(fullPath); } } } private analyzeFile(filePath: string): void { const sourceFile = this.program.getSourceFile(filePath); if (!sourceFile) return; // Pull `@openapi-component` schemas defined anywhere in the file into // the global `components.schemas` registry. this.collectComponentsFromFile(sourceFile); // Visit all nodes in the file ts.forEachChild(sourceFile, node => this.visitNode(node, sourceFile)); } private visitNode(node: ts.Node, sourceFile: ts.SourceFile): void { // Look for class declarations if (ts.isClassDeclaration(node) && node.name) { const className = node.name.text; // Check if it extends a recognized plugin base class const heritage = node.heritageClauses?.find( clause => clause.token === ts.SyntaxKind.ExtendsKeyword ); const baseName = heritage?.types[0]?.expression.getText(sourceFile); if (baseName && this.pluginBaseClasses.has(baseName)) { this.analyzePluginClass(node, sourceFile, className); } } // Recursively visit children ts.forEachChild(node, child => this.visitNode(child, sourceFile)); } private analyzePluginClass( classNode: ts.ClassDeclaration, sourceFile: ts.SourceFile, className: string ): void { // Find the api() method const apiMethod = classNode.members.find( member => ts.isMethodDeclaration(member) && member.name && ts.isIdentifier(member.name) && member.name.text === 'api' ) as ts.MethodDeclaration | undefined; if (!apiMethod || !apiMethod.body) return; // Extract plugin name from 'name = ...' property const nameProp = classNode.members.find( member => ts.isPropertyDeclaration(member) && member.name && ts.isIdentifier(member.name) && member.name.text === 'name' ) as ts.PropertyDeclaration | undefined; let pluginName = className; if (nameProp?.initializer && ts.isStringLiteral(nameProp.initializer)) { pluginName = nameProp.initializer.text; } // Collect local `const X = ...` declarations within the api() body // so we can substitute `${X}` in route paths (e.g. SCIM uses // `const prefix = this.scimPrefix` then `\`${prefix}/Users\``). const localVars = this.collectLocalStringVars(apiMethod.body, classNode); // Analyze the api() method body const routes = this.extractRoutesFromMethod( apiMethod.body, sourceFile, pluginName, localVars ); if (routes.length > 0) { this.routes.set(pluginName, routes); } } /** * Walk the api() method body and resolve every top-level * `const X = ` to a concrete string value, using class * field initializers as fallbacks. Anything we can't resolve statically is * simply skipped — its `${X}` will remain in the path so the bug is visible. */ private collectLocalStringVars( body: ts.Block, classNode: ts.ClassDeclaration ): Map { const vars = new Map(); // Walk recursively so vars declared inside `if (...) { ... }` blocks // (e.g. authzDynamic's optional reload endpoint) still resolve. const visit = (n: ts.Node): void => { if (ts.isVariableStatement(n)) { for (const decl of n.declarationList.declarations) { if (!ts.isIdentifier(decl.name) || !decl.initializer) continue; const value = this.resolveStringExpression( decl.initializer, classNode, vars ); if (value !== undefined) { vars.set(decl.name.text, value); } } } ts.forEachChild(n, visit); }; visit(body); return vars; } /** * Best-effort static resolution of an expression to a string. Handles * string literals, simple template literals, `a || b` fallbacks, and * `this.` references resolved against the class's field * initializers. Returns undefined when the value cannot be determined. */ private resolveStringExpression( expr: ts.Expression, classNode: ts.ClassDeclaration, locals: Map ): string | undefined { if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) { return expr.text; } if (ts.isTemplateExpression(expr)) { let out = expr.head.text; for (const span of expr.templateSpans) { const inner = this.resolveStringExpression( span.expression, classNode, locals ); if (inner === undefined) return undefined; out += inner + span.literal.text; } return out; } if (ts.isIdentifier(expr)) { return locals.get(expr.text); } if (ts.isParenthesizedExpression(expr)) { return this.resolveStringExpression(expr.expression, classNode, locals); } if (ts.isAsExpression(expr) || ts.isTypeAssertionExpression(expr)) { return this.resolveStringExpression(expr.expression, classNode, locals); } if ( ts.isBinaryExpression(expr) && (expr.operatorToken.kind === ts.SyntaxKind.BarBarToken || expr.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) ) { // For `a || b` fallbacks the right-hand side is the static default. // Prefer it; fall back to LHS for completeness. return ( this.resolveStringExpression(expr.right, classNode, locals) ?? this.resolveStringExpression(expr.left, classNode, locals) ); } if (ts.isPropertyAccessExpression(expr)) { // Hardcoded fallbacks for config knobs whose runtime defaults we know. const text = expr.getText(); if (text === 'this.config.api_prefix') return '/api'; if (text === 'this.config.scim_prefix') return '/scim/v2'; // `this.` → look up the class property's initializer. if ( expr.expression.kind === ts.SyntaxKind.ThisKeyword && ts.isIdentifier(expr.name) ) { const fieldName = expr.name.text; const value = this.resolveClassField(classNode, fieldName, locals); if (value !== undefined) return value; } } return undefined; } /** * Resolve a class property to a string by inspecting its declaration * initializer or, if absent, an assignment in the constructor of the * form `this. = `. */ private resolveClassField( classNode: ts.ClassDeclaration, fieldName: string, locals: Map ): string | undefined { const prop = classNode.members.find( m => ts.isPropertyDeclaration(m) && m.name && ts.isIdentifier(m.name) && m.name.text === fieldName ) as ts.PropertyDeclaration | undefined; if (prop?.initializer) { const v = this.resolveStringExpression( prop.initializer, classNode, locals ); if (v !== undefined) return v; } // Look for `this. = ...` assignment in constructor. const ctor = classNode.members.find(m => ts.isConstructorDeclaration(m)) as | ts.ConstructorDeclaration | undefined; if (ctor?.body) { for (const stmt of ctor.body.statements) { if (!ts.isExpressionStatement(stmt)) continue; const e = stmt.expression; if ( ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(e.left) && e.left.expression.kind === ts.SyntaxKind.ThisKeyword && ts.isIdentifier(e.left.name) && e.left.name.text === fieldName ) { const v = this.resolveStringExpression(e.right, classNode, locals); if (v !== undefined) return v; } } } return undefined; } private extractRoutesFromMethod( body: ts.Block, sourceFile: ts.SourceFile, pluginName: string, localVars: Map ): OpenAPIRoute[] { const routes: OpenAPIRoute[] = []; const visit = (node: ts.Node): void => { // Look for app.get(), app.post(), etc. if (ts.isCallExpression(node)) { const expr = node.expression; if (ts.isPropertyAccessExpression(expr)) { const obj = expr.expression.getText(sourceFile); const method = expr.name.text as OpenAPIRoute['method']; if ( obj === 'app' && ['get', 'post', 'put', 'delete', 'patch'].includes(method) ) { const route = this.parseRoute( node, method, sourceFile, pluginName, localVars ); if (route) { routes.push(route); } } } } ts.forEachChild(node, visit); }; visit(body); return routes; } private parseRoute( callExpr: ts.CallExpression, method: OpenAPIRoute['method'], sourceFile: ts.SourceFile, pluginName: string, localVars: Map ): OpenAPIRoute | null { if (callExpr.arguments.length < 2) return null; // First argument is the path const pathArg = callExpr.arguments[0]; let pathTemplate = ''; // Handle template literals with ${this.config.api_prefix} if ( ts.isTemplateExpression(pathArg) || ts.isNoSubstitutionTemplateLiteral(pathArg) ) { const text = pathArg.getText(sourceFile); // Replace template variables with proper values pathTemplate = text .replace(/`/g, '') .replace(/\$\{this\.config\.api_prefix\}/g, '/api') .replace(/\$\{[^}]*api_prefix[^}]*\}/g, '/api') .replace(/\$\{apiPrefix\}/g, '/api') .replace(/\$\{this\.config\.static_name\}/g, 'static') // No leading slash .replace(/\$\{[^}]*static_name[^}]*\}/g, 'static') .replace(/\$\{resourceName\}/g, '{resource}') .replace(/\$\{[^}]*resourceName[^}]*\}/g, '{resource}') // LdapFlat instances each have a different `this.pluralName`; // the spec describes the generic shape with a `{resource}` // placeholder. .replace(/\$\{this\.pluralName\}/g, '{resource}'); // Substitute any remaining `${name}` from the local-const map we // built from the api() body (e.g. `const prefix = this.scimPrefix`). for (const [name, value] of localVars) { const re = new RegExp(`\\$\\{${name}\\}`, 'g'); pathTemplate = pathTemplate.replace(re, value); } } else if (ts.isStringLiteral(pathArg)) { pathTemplate = pathArg.text; } if (!pathTemplate) return null; // Convert Express-style :param to OpenAPI {param} pathTemplate = pathTemplate.replace(/:(\w+)/g, '{$1}'); // Clean up double slashes pathTemplate = pathTemplate.replace(/\/\//g, '/'); // Extract parameters from path const pathParams = this.extractPathParameters(pathTemplate); // Pull any `@openapi` YAML overrides authored above the route. These // are merged on top of the generated defaults below, so a plugin can // override anything (summary, parameters, requestBody, responses, // tags, security, …) without us having to add ad-hoc heuristics. const overrides = this.extractRouteOverrides(callExpr, sourceFile); // Strict opt-in: routes without an `@openapi` block are excluded from // the spec. The published doc should reflect intentionally-documented // surface only, not auto-extracted noise. We log a warning so authors // notice when a new route slips in without documentation. if (Object.keys(overrides).length === 0) { const { line } = sourceFile.getLineAndCharacterOfPosition( callExpr.getStart() ); console.warn( `⚠️ Skipping undocumented route: ${method.toUpperCase()} ${pathTemplate} ` + `(${path.relative(this.rootDir, sourceFile.fileName)}:${line + 1})` ); return null; } const route: OpenAPIRoute = { method, path: pathTemplate, summary: (overrides.summary as string | undefined) || this.generateSummary(method, pathTemplate), description: overrides.description as string | undefined, parameters: pathParams, responses: { '200': { description: 'Successful response', content: { 'application/json': { schema: { type: 'object' }, }, }, }, '400': { description: 'Bad request', }, '401': { description: 'Unauthorized', }, '404': { description: 'Not found', }, '500': { description: 'Internal server error', }, }, tags: [this.pluginTags.get(pluginName) || pluginName], }; // Add requestBody for POST/PUT/PATCH if (['post', 'put', 'patch'].includes(method)) { route.requestBody = { required: true, content: { 'application/json': { schema: { type: 'object' }, }, }, }; // Handle multipart for bulk import if (pathTemplate.includes('bulk-import')) { route.requestBody.content = { 'multipart/form-data': { schema: { type: 'object', properties: { file: { type: 'string', format: 'binary', description: 'CSV file to import', }, dryRun: { type: 'boolean', description: 'Validate without creating entries', }, updateExisting: { type: 'boolean', description: 'Update existing entries', }, continueOnError: { type: 'boolean', description: 'Continue processing on errors', default: true, }, }, required: ['file'], }, }, }; } } // Special handling for CSV template if (method === 'get' && pathTemplate.includes('template.csv')) { route.responses['200'] = { description: 'CSV template', content: { 'text/csv': { schema: { type: 'string' }, }, }, }; } // Deep-merge author-provided overrides last so YAML wins over every // generated default. We strip the `summary`/`description` keys because // they were already consumed when constructing `route`. if (Object.keys(overrides).length > 0) { const { summary: _s, description: _d, ...rest } = overrides; // Special-case: if the author lists `parameters`, concatenate them // with the auto-generated path parameters (deduplicated by `in`+ // `name`). Path params are mandatory in OpenAPI, so silently // dropping them when a route only adds query params would be a // footgun. The override still wins on duplicates. if (Array.isArray(rest.parameters) && route.parameters) { const seen = new Set( (rest.parameters as Array<{ in?: string; name?: string }>).map( p => `${p.in}:${p.name}` ) ); const merged = [ ...route.parameters.filter(p => !seen.has(`${p.in}:${p.name}`)), ...(rest.parameters as OpenAPIRoute['parameters']), ]; rest.parameters = merged as unknown as Record[]; } const merged = this.deepMerge( route as unknown as Record, rest ); return merged as unknown as OpenAPIRoute; } return route; } private extractPathParameters(path: string): OpenAPIRoute['parameters'] { const params: OpenAPIRoute['parameters'] = []; const matches = path.matchAll(/\{(\w+)\}/g); for (const match of matches) { params.push({ name: match[1], in: 'path', required: true, schema: { type: 'string' }, description: `${match[1]} parameter`, }); } return params.length > 0 ? params : undefined; } private getLeadingComments(node: ts.Node, sourceFile: ts.SourceFile): string { const fullText = sourceFile.getFullText(); const ranges = ts.getLeadingCommentRanges(fullText, node.getFullStart()); if (!ranges) return ''; // Only keep JSDoc-style block comments (`/** ... *\/`). Plain `//` // line comments above a route are author notes — never directives — // and folding them in would let lines like `// Rename group` leak // into the YAML body of an `@openapi` block above them. const jsdocs = ranges .filter( range => range.kind === ts.SyntaxKind.MultiLineCommentTrivia && fullText.startsWith('/**', range.pos) ) .map(range => fullText.substring(range.pos, range.end)); // The closest JSDoc block (the last one) is the one annotating this // node; older blocks belong to whatever appeared before. return jsdocs.length > 0 ? jsdocs[jsdocs.length - 1] : ''; } /** * Strip JSDoc comment delimiters and the leading `*` from each line so * the remaining text is plain YAML/markdown. Handles both `/** ... *\/` * blocks and `// ...` line comments. */ private stripCommentDelimiters(comment: string): string { return comment .replace(/^\s*\/\*\*?/, '') .replace(/\*\/\s*$/, '') .split('\n') .map(line => line.replace(/^\s*\*\s?/, '').replace(/^\s*\/\/\s?/, '')) .join('\n'); } /** * Extract every `@openapi`-style block (including `@openapi-component`) * from a JSDoc comment. Each match returns the directive name and the * YAML body that follows it, up to the next `@`-directive on a fresh * line or the end of the comment. */ private extractDirectiveBlocks(comment: string, directive: string): string[] { const stripped = this.stripCommentDelimiters(comment); const lines = stripped.split('\n'); const blocks: string[] = []; const startRe = new RegExp(`^\\s*@${directive}\\s*$`); const anyDirective = /^\s*@[\w-]+(\s|$)/; for (let i = 0; i < lines.length; i++) { if (!startRe.test(lines[i])) continue; const buf: string[] = []; let j = i + 1; // Drop a single uniform leading indent so YAML alignment survives // the JSDoc star prefix being stripped. while (j < lines.length && !anyDirective.test(lines[j])) { buf.push(lines[j]); j++; } blocks.push(this.dedent(buf.join('\n'))); i = j - 1; } return blocks; } private dedent(text: string): string { const lines = text.replace(/\s+$/, '').split('\n'); let indent = Infinity; for (const line of lines) { if (!line.trim()) continue; const m = line.match(/^( *)/); if (m) indent = Math.min(indent, m[1].length); } if (!isFinite(indent) || indent === 0) return lines.join('\n'); return lines.map(l => l.slice(indent)).join('\n'); } private parseYamlBlock(block: string, where: string): unknown | undefined { if (!block.trim()) return undefined; try { return yaml.load(block); } catch (err) { console.warn( `⚠️ Failed to parse YAML in ${where}: ${(err as Error).message}` ); return undefined; } } /** * Read every `@openapi` block above a route call: returns a partial * Operation Object (summary, description, parameters, requestBody, * responses, tags, ...) that the generator can deep-merge over its * defaults. */ private extractRouteOverrides( callExpr: ts.CallExpression, sourceFile: ts.SourceFile ): Record { const comments = this.getLeadingComments(callExpr, sourceFile); if (!comments) return {}; const blocks = this.extractDirectiveBlocks(comments, 'openapi'); let merged: Record = {}; for (const block of blocks) { const parsed = this.parseYamlBlock( block, `route at ${path.relative(this.rootDir, sourceFile.fileName)}:${ sourceFile.getLineAndCharacterOfPosition(callExpr.getStart()).line + 1 }` ); if (parsed && typeof parsed === 'object') { merged = this.deepMerge(merged, parsed as Record); } } return merged; } /** * Walk a source file for `@openapi-component` blocks. Each block is a * YAML map of `: ` pairs that get merged into * `components.schemas`. Looking at the whole file (not just the class) * lets contributors group related schemas in a single block at the top. */ private collectComponentsFromFile(sourceFile: ts.SourceFile): void { const fullText = sourceFile.getFullText(); const commentRe = /\/\*\*[\s\S]*?\*\//g; let match: RegExpExecArray | null; while ((match = commentRe.exec(fullText)) !== null) { const blocks = this.extractDirectiveBlocks(match[0], 'openapi-component'); for (const block of blocks) { const parsed = this.parseYamlBlock( block, `@openapi-component in ${path.relative(this.rootDir, sourceFile.fileName)}` ); if (parsed && typeof parsed === 'object') { Object.assign(this.components, parsed as Record); } } } } /** * Recursively merge `override` over `base`. Arrays are replaced (not * concatenated) so route authors retain full control of e.g. the list * of `parameters`. */ private deepMerge>( base: T, override: Record ): T { const out: Record = { ...base }; for (const [k, v] of Object.entries(override)) { const existing = out[k]; if ( v && typeof v === 'object' && !Array.isArray(v) && existing && typeof existing === 'object' && !Array.isArray(existing) ) { out[k] = this.deepMerge( existing as Record, v as Record ); } else { out[k] = v; } } return out as T; } private generateSummary(method: string, path: string): string { const action = { get: 'Get', post: 'Create', put: 'Update', delete: 'Delete', patch: 'Modify', }[method] || method.toUpperCase(); // Extract resource from path const parts = path.split('/').filter(p => p && !p.startsWith('{')); const resource = parts[parts.length - 1] || 'resource'; return `${action} ${resource}`; } /** * Generate OpenAPI spec */ public generateSpec(): OpenAPISpec { const spec: OpenAPISpec = { 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', }, { url: '{protocol}://{host}:{port}', description: 'Custom server', variables: { protocol: { default: 'http', enum: ['http', 'https'], description: 'Protocol scheme', }, host: { default: 'localhost', description: 'Host name', }, port: { default: '8081', description: 'Port number', }, }, }, ], paths: {}, tags: Array.from(new Set(this.pluginTags.values())).map(tag => ({ name: tag, description: `${tag} operations`, })), }; // Build paths. We carry every route property over (via spread) so any // OpenAPI Operation Object field provided through `@openapi` (e.g. // `security`, `deprecated`, `operationId`) flows through unchanged. for (const [, routes] of this.routes) { for (const route of routes) { if (!spec.paths[route.path]) { spec.paths[route.path] = {}; } const { method, path: _p, ...op } = route as unknown as Record; spec.paths[route.path][method as string] = op; } } if (Object.keys(this.components).length > 0) { spec.components = { schemas: this.components }; } return spec; } /** * Write spec to file */ public writeSpec(outputPath: string): void { const spec = this.generateSpec(); fs.writeFileSync(outputPath, JSON.stringify(spec, null, 2), 'utf-8'); console.log(`✅ OpenAPI spec generated: ${outputPath}`); console.log(` Found ${this.routes.size} plugins`); console.log(` Generated ${Object.keys(spec.paths).length} paths`); if (spec.components?.schemas) { console.log( ` Collected ${Object.keys(spec.components.schemas).length} components` ); } } } // Main const rootDir = path.join(__dirname, '..'); const outputPath = path.join(rootDir, 'openapi.json'); try { console.log('🔍 Analyzing LDAP-Rest plugins...'); const generator = new OpenAPIGenerator(rootDir); generator.analyze(); generator.writeSpec(outputPath); console.log('✨ Done!'); } catch (error) { console.error('❌ Error:', error); process.exit(1); } linagora-ldap-rest-16e557e/scripts/moveBrowserLibs.mjs000066400000000000000000000030161522642357000230760ustar00rootroot00000000000000#!/usr/bin/env node /** * Post-build script to move browser libraries from dist/ to static/ * while keeping types in dist/ */ import { mkdirSync, readdirSync, renameSync, statSync, copyFileSync } from 'fs'; import { join } from 'path'; const srcDir = 'dist/browser'; const destDir = 'static/browser'; function moveJsFiles(src, dest) { mkdirSync(dest, { recursive: true }); const entries = readdirSync(src); for (const entry of entries) { const srcPath = join(src, entry); const destPath = join(dest, entry); const stat = statSync(srcPath); if (stat.isDirectory()) { // Recursively move JS files from subdirectories moveJsFiles(srcPath, destPath); } else if (entry.endsWith('.js') || entry.endsWith('.js.map')) { // Move JS and source map files console.log(`Moving ${srcPath} → ${destPath}`); renameSync(srcPath, destPath); } // Skip .d.ts files - they stay in dist/ } } function copySharedCss() { const srcCss = 'src/browser/shared/styles/common-demo.css'; const destCss = 'static/browser/common-demo.css'; try { console.log(`Copying ${srcCss} → ${destCss}`); copyFileSync(srcCss, destCss); console.log('✓ Shared CSS copied to static/browser/'); } catch (error) { console.error('Error copying shared CSS:', error); } } try { moveJsFiles(srcDir, destDir); console.log('✓ Browser libraries moved to static/browser/'); copySharedCss(); } catch (error) { console.error('Error moving browser libraries:', error); process.exit(1); } linagora-ldap-rest-16e557e/src/000077500000000000000000000000001522642357000163375ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/abstract/000077500000000000000000000000001522642357000201425ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/abstract/ldapFlat.ts000066400000000000000000001115101522642357000222400ustar00rootroot00000000000000/** * @module abstract/ldapFlat * @author Xavier Guimard * * Abstract class to manage LDAP entries in a flat branch * - add/delete entries * - modify entries * - validate with schema */ import fs from 'fs'; import type { Express, Request, Response } from 'express'; import type { DM } from '../bin'; import type ldapActions from '../lib/ldapActions'; import type { AttributesList, AttributeValue, LdapList, ModifyRequest, SearchResult, } from '../lib/ldapActions'; import { created, jsonBody, tryMethod, wantJson, } from '../lib/expressFormatedResponses'; import { asyncHandler, escapeDnValue, escapeLdapFilter, escapeRegex, getCompiledRegex, getParentDn, launchHooks, launchHooksChained, transformSchemas, validateDnValue, } from '../lib/utils'; import type { Schema } from '../config/schema'; import { BadRequestError, NotFoundError, ConflictError } from '../lib/errors'; import DmPlugin from './plugin'; export interface LdapFlatConfig { /** * LDAP branch where entries are stored */ base: string; /** * Main attribute used as entry identifier (e.g., 'uid', 'cn') */ mainAttribute: string; /** * ObjectClass(es) for new entries */ objectClass: string[]; /** * Default attributes to add to new entries */ defaultAttributes?: AttributesList; /** * Optional schema file path for validation */ schemaPath?: string; /** * Singular name for API routes (e.g., 'user', 'position') */ singularName: string; /** * Plural name for API routes (e.g., 'users', 'positions') */ pluralName: string; /** * Hook name prefix (e.g., 'ldapuser', 'ldapposition') */ hookPrefix: string; } /** * Generic OpenAPI schemas for the flat LDAP-entry CRUD surface. * Every concrete plugin that extends LdapFlat (users, mailgroups, …) * exposes these shapes, with `mainAttribute` and concrete attribute * names varying per instance. * * @openapi-component * FlatEntry: * type: object * description: | * A single LDAP entry from a flat branch. The `dn` field is always * present; all other fields depend on the concrete plugin's schema * (e.g. `uid`, `cn`, `mail`, `sn` for users). * required: [dn] * properties: * dn: * type: string * description: Fully-qualified distinguished name of the entry. * example: uid=alice,ou=users,dc=example,dc=com * additionalProperties: * oneOf: * - type: string * - type: array * items: { type: string } * example: * dn: uid=alice,ou=users,dc=example,dc=com * uid: alice * cn: Alice Smith * sn: Smith * mail: alice@example.com * FlatList: * type: object * description: | * A map of entries returned by the LIST endpoint, keyed by the * entry's `mainAttribute` value (e.g. the `uid` for users). This * is the `LdapList` shape (`Record`). * additionalProperties: * $ref: '#/components/schemas/FlatEntry' * example: * alice: * dn: uid=alice,ou=users,dc=example,dc=com * uid: alice * cn: Alice Smith * mail: alice@example.com * bob: * dn: uid=bob,ou=users,dc=example,dc=com * uid: bob * cn: Bob Jones * mail: bob@example.com * FlatCreate: * type: object * description: | * Body for creating a new entry. The plugin's `mainAttribute` field * (e.g. `uid`) is required and used to build the DN. All other * attributes are plugin-specific; unknown attributes are accepted * unless the plugin's schema is in strict mode. * additionalProperties: true * example: * uid: carol * cn: Carol White * sn: White * mail: carol@example.com * FlatModify: * type: object * description: | * Partial-update body. Supports `add`, `replace`, and `delete` * maps matching the LDAP modify operation. The `mainAttribute` * (e.g. `uid`) cannot be changed through this endpoint — use the * rename API instead. * properties: * add: * type: object * description: Attributes whose values are appended. * additionalProperties: true * replace: * type: object * description: Attributes whose values are replaced wholesale. * additionalProperties: true * delete: * description: | * Attributes (or specific values) to remove. Either an array * of attribute names or a map of attribute → value(s). * oneOf: * - type: array * items: { type: string } * - type: object * additionalProperties: true * example: * replace: * mail: carol.white@example.com * cn: Carol White-Smith * FlatMoveRequest: * type: object * description: | * Body for moving an entry to a different organisational unit. * Requires `ldap_organization_link_attribute` and * `ldap_organization_path_attribute` to be defined in the plugin's * schema. * required: [targetOrgDn] * properties: * targetOrgDn: * type: string * description: DN of the destination organisational unit. * example: ou=engineering,ou=departments,dc=example,dc=com * example: * targetOrgDn: ou=engineering,ou=departments,dc=example,dc=com */ export default abstract class LdapFlat extends DmPlugin { base: string; ldap: ldapActions; mainAttribute: string; objectClass: string[]; defaultAttributes: AttributesList; schema?: Schema; singularName: string; pluralName: string; hookPrefix: string; constructor(server: DM, config: LdapFlatConfig) { super(server); this.ldap = server.ldap; this.base = config.base; this.mainAttribute = config.mainAttribute; this.objectClass = config.objectClass; this.defaultAttributes = config.defaultAttributes || {}; this.singularName = config.singularName; this.pluralName = config.pluralName; this.hookPrefix = config.hookPrefix; if (!this.base) { throw new Error(`LDAP base is not defined for ${this.singularName}`); } if (config.schemaPath) { try { const data = fs.readFileSync(config.schemaPath, 'utf8'); this.schema = JSON.parse(transformSchemas(data, this.config)) as Schema; this.logger.info( `${this.singularName} schema loaded from ${config.schemaPath}` ); } catch (err) { this.logger.error( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Failed to load ${this.singularName} schema from ${config.schemaPath}: ${err}` ); } } } /** * API routes */ api(app: Express): void { /** * @openapi * summary: List entries * description: | * Returns all entries in the flat LDAP branch, keyed by their * `mainAttribute` value (e.g. `uid` for users). The optional * `match` and `attribute` query parameters filter results using * a substring LDAP search (`attribute=*match*`). The `attributes` * parameter limits which LDAP attributes are returned. * tags: * - Entities * parameters: * - in: path * name: resource * required: true * schema: { type: string } * description: | * Plural name of the flat resource (e.g. `users`, `mailgroups`). * Each concrete plugin sets its own value. * example: users * - in: query * name: match * required: false * schema: { type: string } * description: | * Substring to match. Must be used together with `attribute`. * The resulting LDAP filter is `(attribute=*match*)`. * example: alice * - in: query * name: attribute * required: false * schema: { type: string } * description: | * LDAP attribute name to match against (used with `match`). * example: cn * - in: query * name: attributes * required: false * schema: { type: string } * description: | * Comma-separated list of LDAP attributes to include in each * returned entry. Omit to return all attributes. * example: uid,cn,mail * responses: * '200': * description: Map of entries keyed by mainAttribute value. * content: * application/json: * schema: { $ref: '#/components/schemas/FlatList' } * example: * alice: * dn: uid=alice,ou=users,dc=example,dc=com * uid: alice * cn: Alice Smith * mail: alice@example.com * bob: * dn: uid=bob,ou=users,dc=example,dc=com * uid: bob * cn: Bob Jones * mail: bob@example.com * '400': * description: Invalid LDAP attribute name in `attribute` parameter. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // List entries app.get( `${this.config.api_prefix}/v1/ldap/${this.pluralName}`, asyncHandler(async (req, res) => { if (!wantJson(req, res)) return; const args: { filter?: string; attributes?: string[] } = {}; if ( req.query.match && typeof req.query.match === 'string' && req.query.attribute && typeof req.query.attribute === 'string' ) { // Validate LDAP attribute name (alphanumeric + hyphen, starting with letter) const attributePattern = /^[a-zA-Z][a-zA-Z0-9-]*$/; if (!attributePattern.test(req.query.attribute)) { throw new BadRequestError('Invalid LDAP attribute name'); } // Escape filter value to prevent LDAP injection const escapedMatch = escapeLdapFilter(req.query.match); args.filter = `(${req.query.attribute}=*${escapedMatch}*)`; } if (req.query.attributes && typeof req.query.attributes === 'string') { args.attributes = req.query.attributes.split(','); } const list = await this.listEntries(args); res.json(list); }) ); /** * @openapi * summary: Get entry by ID or DN * description: | * Returns a single entry identified by its `mainAttribute` value * (e.g. a `uid`) or by its full DN. If the value starts with * `mainAttribute=` it is treated as a DN; otherwise it is treated * as a raw RDN value. * tags: * - Entities * parameters: * - in: path * name: resource * required: true * schema: { type: string } * description: | * Plural name of the flat resource (e.g. `users`, `mailgroups`). * Each concrete plugin sets its own value. * example: users * responses: * '200': * description: The requested entry. * content: * application/json: * schema: { $ref: '#/components/schemas/FlatEntry' } * example: * dn: uid=alice,ou=users,dc=example,dc=com * uid: alice * cn: Alice Smith * sn: Smith * mail: alice@example.com * '400': * description: The supplied DN is not a direct child of the configured base. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } * '404': * description: Entry not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } * example: * error: user not found * code: 404 */ // Get entry by id or DN app.get( `${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`, asyncHandler(async (req, res) => this.apiGet(req, res)) ); /** * @openapi * summary: Create entry * description: | * Creates a new entry in the flat LDAP branch. The `mainAttribute` * field (e.g. `uid`) is required and used to construct the DN. * Returns the newly created entry on success (HTTP 201). * tags: * - Entities * parameters: * - in: path * name: resource * required: true * schema: { type: string } * description: | * Plural name of the flat resource (e.g. `users`, `mailgroups`). * Each concrete plugin sets its own value. * example: users * requestBody: * required: true * content: * application/json: * schema: { $ref: '#/components/schemas/FlatCreate' } * example: * uid: carol * cn: Carol White * sn: White * mail: carol@example.com * responses: * '201': * description: Entry created; returns the new entry. * content: * application/json: * schema: { $ref: '#/components/schemas/FlatEntry' } * example: * dn: uid=carol,ou=users,dc=example,dc=com * uid: carol * cn: Carol White * sn: White * mail: carol@example.com * '400': * description: Missing required field or validation error. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } * '409': * description: Entry already exists. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Add entry app.post( `${this.config.api_prefix}/v1/ldap/${this.pluralName}`, asyncHandler(async (req, res) => this.apiAdd(req, res)) ); /** * @openapi * summary: Delete entry * description: | * Permanently removes the entry identified by `id` from the LDAP * branch. The `id` may be either a `mainAttribute` value or the * entry's full DN. * tags: * - Entities * parameters: * - in: path * name: resource * required: true * schema: { type: string } * description: | * Plural name of the flat resource (e.g. `users`, `mailgroups`). * Each concrete plugin sets its own value. * example: users * responses: * '200': * description: Entry deleted. * content: * application/json: * example: { success: true } * '404': * description: Entry not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Delete entry app.delete( `${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`, asyncHandler(async (req, res) => this.apiDelete(req, res)) ); /** * @openapi * summary: Modify entry * description: | * Applies a partial update to the entry identified by `id`. The * body follows the LDAP modify structure: `add`, `replace`, and/or * `delete` maps. The `mainAttribute` (e.g. `uid`) cannot be * changed through this endpoint. * tags: * - Entities * parameters: * - in: path * name: resource * required: true * schema: { type: string } * description: | * Plural name of the flat resource (e.g. `users`, `mailgroups`). * Each concrete plugin sets its own value. * example: users * requestBody: * required: true * content: * application/json: * schema: { $ref: '#/components/schemas/FlatModify' } * example: * replace: * mail: alice.smith@example.com * cn: Alice M. Smith * responses: * '200': * description: Entry updated. * content: * application/json: * example: { success: true } * '400': * description: Validation error or attempt to modify a fixed attribute. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } * '404': * description: Entry not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Modify entry app.put( `${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id`, asyncHandler(async (req, res) => this.apiModify(req, res)) ); /** * @openapi * summary: Move entry to another organisation * description: | * Moves the entry to a different organisational unit by updating * the configured department-link and department-path attributes * (`ldap_organization_link_attribute` and * `ldap_organization_path_attribute`). The plugin's schema must * declare both attributes; otherwise the request is rejected with * 400. * tags: * - Entities * parameters: * - in: path * name: resource * required: true * schema: { type: string } * description: | * Plural name of the flat resource (e.g. `users`, `mailgroups`). * Each concrete plugin sets its own value. * example: users * requestBody: * required: true * content: * application/json: * schema: { $ref: '#/components/schemas/FlatMoveRequest' } * example: * targetOrgDn: ou=engineering,ou=departments,dc=example,dc=com * responses: * '200': * description: Entry moved; returns the new department path and link. * content: * application/json: * example: * success: true * departmentPath: /engineering * departmentLink: ou=engineering,ou=departments,dc=example,dc=com * '400': * description: | * Missing or invalid `targetOrgDn`, or schema does not support * the move operation. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } * '404': * description: Entry or target organisation not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Move entry to different organization app.post( `${this.config.api_prefix}/v1/ldap/${this.pluralName}/:id/move`, asyncHandler(async (req, res) => this.apiMove(req, res)) ); } async apiGet(req: Request, res: Response): Promise { if (!wantJson(req, res)) return; const id = decodeURIComponent(req.params.id as string); try { const dn = this.resolveDn(id); const result = (await this.ldap.search( { paged: false, scope: 'base' }, dn )) as SearchResult; if (result.searchEntries.length === 0) { throw new NotFoundError(`${this.singularName} not found`); } res.json(result.searchEntries[0]); } catch (err) { // LDAP NoSuchObjectError (code 32) means not found if ( (err as { code?: number }).code && (err as { code?: number }).code === 32 ) { throw new NotFoundError(`${this.singularName} not found`); } throw err; } } async apiAdd(req: Request, res: Response): Promise { const body = jsonBody(req, res, this.mainAttribute) as | Record | false; if (!body) return; const id = body[this.mainAttribute] as string; const additional = { ...body }; delete additional[this.mainAttribute]; // Remove dn if provided - it will be constructed by addEntry delete additional.dn; await this.addEntry(id, additional, req); const entry = await this.searchEntriesByName(id, false); return created(res, entry[id]); } async apiDelete(req: Request, res: Response): Promise { if (!wantJson(req, res)) return; const id = decodeURIComponent(req.params.id as string); await tryMethod(res, this.deleteEntry.bind(this), id); } async apiModify(req: Request, res: Response): Promise { const body = jsonBody(req, res) as ModifyRequest | false; if (!body) return; const id = decodeURIComponent(req.params.id as string); await tryMethod(res, this.modifyEntry.bind(this), id, body); } async apiMove(req: Request, res: Response): Promise { if (!wantJson(req, res)) return; const body = jsonBody(req, res, 'targetOrgDn') as | { targetOrgDn: string } | false; if (!body) return; const id = decodeURIComponent(req.params.id as string); const { targetOrgDn } = body; if (!targetOrgDn || typeof targetOrgDn !== 'string') { throw new BadRequestError( 'Missing or invalid targetOrgDn in request body' ); } const result = await this.moveEntry(id, targetOrgDn, req); res.json({ success: true, ...result, }); } /** * Resolve an id (either an RDN value or a full DN) into a DN inside this * instance's base branch. * * A value is treated as a full DN only when it starts with * `mainAttribute=`; otherwise it is treated as a raw RDN value and escaped. * This matters for cn-based branches where values like `Smith, John` are * legal and must not be misclassified as DNs. * * When the id is a full DN we enforce that its parent equals this.base * (LdapFlat entries are, by definition, direct children of the base). * The comparison is done on the parsed RDN components so escape-aware * payloads like `cn=pwn\,ou=titles,ou=nomenclature,dc=example,dc=com` * cannot sneak past a naive textual suffix check: `\,` is a literal comma * inside the first RDN's value, so the actual parent of that DN is * `ou=nomenclature,dc=example,dc=com` — one level above the expected base. * * @throws BadRequestError if the provided DN is not a direct child of this.base */ protected resolveDn(id: string): string { const dnPrefix = new RegExp(`^${escapeRegex(this.mainAttribute)}=`, 'i'); if (dnPrefix.test(id)) { const parent = getParentDn(id); // If the DN has a single RDN, getParentDn returns the DN itself; treat // that as "missing parent" rather than "parent equals base". const hasParent = parent !== id; if (!hasParent || parent.toLowerCase() !== this.base.toLowerCase()) { throw new BadRequestError( `DN must be a direct child of "${this.base}". ` + `Provided DN "${id}" has parent "${hasParent ? parent : ''}"` ); } return id; } return `${this.mainAttribute}=${escapeDnValue(id)},${this.base}`; } async addEntry( id: string, additional: AttributesList = {}, req?: Request ): Promise { let dn: string; if (new RegExp(`^${this.mainAttribute}=`, 'i').test(id)) { // DN provided - validate it's in the correct flat branch dn = this.resolveDn(id); // Extract the RDN value, correctly handling escaped commas // e.g. uid=Smith\,John,ou=users -> id = "Smith\,John" id = id.replace( new RegExp(`^${this.mainAttribute}=((?:\\\\.|[^,])+)(?:,.*)?$`, 'i'), '$1' ); } else { validateDnValue(id, this.mainAttribute); dn = this.resolveDn(id); } await this.validateNewEntry(dn, { objectClass: this.objectClass, [this.mainAttribute]: id, ...additional, }); // Build entry let entry: AttributesList = { objectClass: this.objectClass, ...this.defaultAttributes, [this.mainAttribute]: id, ...additional, }; // Note: LDAP attribute values with DNs do NOT need escaping // ldapts handles this automatically // Only the main DN of the entry itself needs proper formatting if (this.schema) { this.logger.debug( 'Schema loaded, attributes:', Object.keys(this.schema.attributes) ); } else { this.logger.warn('No schema available'); } [dn, entry] = await launchHooksChained( this.registeredHooks[`${this.hookPrefix}add`], [dn, entry] ); // Debug log entry before sending to LDAP this.logger.debug('Adding LDAP entry:', { dn, entry: JSON.stringify(entry, null, 2), }); // Log each attribute to see exact values this.logger.debug('Entry attributes breakdown:'); for (const [key, value] of Object.entries(entry)) { this.logger.debug(` ${key}: ${typeof value} = ${JSON.stringify(value)}`); } let res; try { res = await this.ldap.add(dn, entry, req); } catch (err) { // Log detailed error information this.logger.error('LDAP add failed:', { dn, entry: JSON.stringify(entry, null, 2), error: err, }); // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Failed to add ${this.singularName} ${dn}: ${err}`); } void launchHooks(this.registeredHooks[`${this.hookPrefix}adddone`], [ dn, entry, ]); return res; } async modifyEntry(id: string, changes: ModifyRequest): Promise { let dn = this.resolveDn(id); const op = this.opNumber(); [dn, changes] = await launchHooksChained( this.registeredHooks[`${this.hookPrefix}modify`], [dn, changes, op] ); if (changes.add) { if (changes.add[this.mainAttribute]) throw new ConflictError( `${this.mainAttribute} attribute is unique, cannot add` ); } if (changes.delete) { if (changes.delete instanceof Object) { if ((changes.delete as AttributesList)[this.mainAttribute]) throw new BadRequestError( `Cannot delete ${this.mainAttribute} attribute` ); } if (Array.isArray(changes.delete)) { if (changes.delete.includes(this.mainAttribute)) throw new BadRequestError( `Cannot delete ${this.mainAttribute} attribute` ); } } if (changes.replace) { if (changes.replace[this.mainAttribute]) throw new BadRequestError( `Use dedicated API to change ${this.mainAttribute} attribute` ); } await this.validateChanges(dn, changes); const res = await this.ldap.modify(dn, changes); void launchHooks(this.registeredHooks[`${this.hookPrefix}modifydone`], [ dn, changes, op, ]); return res; } async renameEntry(id: string, newId: string): Promise { if (!/,/.test(id)) { validateDnValue(id, this.mainAttribute); } if (!/,/.test(newId)) { validateDnValue(newId, this.mainAttribute); } let dn = this.resolveDn(id); let newDn = this.resolveDn(newId); [dn, newDn] = await launchHooksChained( this.registeredHooks[`${this.hookPrefix}rename`], [dn, newDn] ); const res = await this.ldap.rename(dn, newDn); void launchHooks(this.registeredHooks[`${this.hookPrefix}renamedone`], [ dn, newDn, ]); return res; } async deleteEntry(id: string): Promise { let dn = this.resolveDn(id); dn = await launchHooksChained( this.registeredHooks[`${this.hookPrefix}delete`], dn ); const res = await this.ldap.delete(dn); void launchHooks(this.registeredHooks[`${this.hookPrefix}deletedone`], dn); return res; } /** * Move entry to a different organization * Updates department link and path attributes */ async moveEntry( id: string, targetOrgDn: string, req?: Request ): Promise<{ departmentPath: string; departmentLink: string }> { const dn = this.resolveDn(id); // Get link and path attribute names from config const linkAttr = this.config.ldap_organization_link_attribute || 'twakeDepartmentLink'; const pathAttr = this.config.ldap_organization_path_attribute || 'twakeDepartmentPath'; // Validate that the schema supports these attributes if ( !this.schema?.attributes[linkAttr] || !this.schema?.attributes[pathAttr] ) { throw new BadRequestError( `Schema for ${this.singularName} does not support move operation (missing ${linkAttr} or ${pathAttr})` ); } // Fetch current entry to get old organization const currentEntry = (await this.ldap.search( { paged: false, scope: 'base', attributes: [linkAttr] }, dn, req )) as SearchResult; if ( !currentEntry.searchEntries || currentEntry.searchEntries.length === 0 ) { throw new NotFoundError(`Entry ${dn} not found`); } // Get department path from target organization const departmentPath = await this.getDepartmentPath(targetOrgDn, req); // Launch pre-move hook (chained - can modify targetOrgDn or cancel) [, targetOrgDn] = await launchHooksChained( this.registeredHooks[`${this.hookPrefix}move`], [dn, targetOrgDn, req] ); // Prepare LDAP modify request const changes: ModifyRequest = { replace: { [linkAttr]: targetOrgDn, [pathAttr]: departmentPath, }, }; // Execute the modification (will trigger onLdapChange hook) await this.modifyEntry(id, changes); return { departmentPath, departmentLink: targetOrgDn, }; } /** * Get department path from an organization DN * Fetches the path attribute directly from the organization entry */ private async getDepartmentPath( orgDn: string, req?: Request ): Promise { const pathAttr = this.config.ldap_organization_path_attribute || 'twakeDepartmentPath'; try { const result = (await this.ldap.search( { paged: false, scope: 'base', attributes: [pathAttr, 'ou', 'o'] }, orgDn, req )) as SearchResult; if (!result.searchEntries || result.searchEntries.length === 0) { throw new NotFoundError(`Organization ${orgDn} not found`); } const org = result.searchEntries[0]; // Return the path attribute if it exists if (org[pathAttr]) { const path = org[pathAttr]; return Array.isArray(path) ? String(path[0]) : String(path); } // Fallback: construct path from ou or o attribute const ou = org.ou || org.o; if (ou) { const name = Array.isArray(ou) ? String(ou[0]) : String(ou); return `/${name}`; } // Last resort: use the DN this.logger.warn( `Organization ${orgDn} has no ${pathAttr} attribute, using DN` ); return orgDn; } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Failed to fetch organization ${orgDn}: ${err}`); } } /** * List entries from LDAP */ async listEntries({ filter, attributes, }: { filter?: string; attributes?: string[]; }): Promise { filter = filter || '(objectClass=*)'; const args: { paged: boolean; filter: string; attributes?: string[]; } = { paged: true, filter, }; if (attributes && attributes.length > 0) args.attributes = attributes; const ldapRes = await this.ldap.search(args, this.base); const res: LdapList = {}; for await (const tmp of ldapRes as AsyncGenerator) { tmp.searchEntries.forEach(e => { if (e[this.mainAttribute]) { const value = e[this.mainAttribute]; let id: string; if (Array.isArray(value)) { id = typeof value[0] === 'string' ? value[0] : String(value[0]); } else { id = typeof value === 'string' ? value : String(value); } res[id] = e; } }); } return res; } async searchEntriesByName( name: string, partial = false, attrs: string[] = [this.mainAttribute] ): Promise { const filter = partial ? `(${this.mainAttribute}=*${name}*)` : `(${this.mainAttribute}=${name})`; return await this.listEntries({ filter, attributes: attrs }); } async validateNewEntry(dn: string, entry: AttributesList): Promise { if (!this.schema) return true; // First, enforce fixed attributes for (const [field, attr] of Object.entries(this.schema.attributes)) { if (attr.fixed && attr.default !== undefined) { // Force the default value for fixed attributes entry[field] = attr.default; } } for (const [field, value] of Object.entries(entry)) { if (!this.schema.attributes[field]) { if (this.schema.strict) throw new BadRequestError( `Unknown attribute "${field}" for ${this.singularName}` ); continue; } const attr = this.schema.attributes[field]; // Check if trying to modify a fixed attribute if (attr.fixed && attr.default !== undefined) { const defaultStr = JSON.stringify(attr.default); const valueStr = JSON.stringify(value); if (defaultStr !== valueStr) { throw new BadRequestError( `Attribute "${field}" is fixed and cannot be modified. Expected: ${defaultStr}` ); } } if (!(await this._validateOneChange(field, value))) { throw new BadRequestError(`Invalid value for attribute "${field}"`); } if (attr.required && !value) { throw new BadRequestError(`Attribute "${field}" is required`); } } // Check required fields for (const [field, attr] of Object.entries(this.schema.attributes)) { if (attr.required && !entry[field]) { throw new BadRequestError(`Attribute "${field}" is required`); } } return true; } async validateChanges(dn: string, changes: ModifyRequest): Promise { if (!this.schema) return true; // Check for fixed attributes in add/replace operations // eslint-disable-next-line @typescript-eslint/no-unused-vars const checkFixed = (field: string, value: AttributeValue): void => { const attr = this.schema?.attributes[field]; if (attr?.fixed) { throw new BadRequestError( `Attribute "${field}" is fixed and cannot be modified` ); } }; if (changes.add) { for (const [field, value] of Object.entries(changes.add)) { checkFixed(field, value); if (!(await this._validateOneChange(field, value))) { throw new BadRequestError(`Invalid value for attribute "${field}"`); } } } if (changes.replace) { for (const [field, value] of Object.entries(changes.replace)) { checkFixed(field, value); if (!(await this._validateOneChange(field, value))) { throw new BadRequestError(`Invalid value for attribute "${field}"`); } } } if (changes.delete) { const deleteFields = Array.isArray(changes.delete) ? changes.delete : Object.keys(changes.delete); for (const field of deleteFields) { const attr = this.schema.attributes[field]; if (attr?.fixed) { throw new BadRequestError( `Attribute "${field}" is fixed and cannot be deleted` ); } } } return true; } async _validateOneChange( field: string, value: AttributeValue | null ): Promise { if (!this.schema) return true; const attr = this.schema.attributes[field]; if (!attr) { if (this.schema.strict) return false; return true; } if (!value) return true; // Handle pointer type if (attr.type === 'pointer') { if (typeof value !== 'string') { throw new BadRequestError( `Field ${field} must be a string (DN pointer)` ); } const dnValue: string = value; // Check branch restriction if provided if (attr.branch && attr.branch.length > 0) { const isInBranch = attr.branch.some(branch => { const branchPattern = getCompiledRegex( `,?${escapeRegex(branch)}$`, 'i' ); return branchPattern.test(dnValue); }); if (!isInBranch) { throw new BadRequestError( `Field ${field} must point to a DN within allowed branches: ${attr.branch.join(', ')}` ); } } // Verify that the DN exists in LDAP try { const result = (await this.ldap.search( { paged: false, scope: 'base' }, dnValue )) as SearchResult; if ( !result || !result.searchEntries || result.searchEntries.length === 0 ) throw new BadRequestError( `Field ${field} points to non-existent DN: ${dnValue}` ); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (err) { throw new BadRequestError( `Field ${field} points to invalid or non-existent DN: ${dnValue}` ); } } if (attr.test) { const regex = typeof attr.test === 'string' ? getCompiledRegex(attr.test) : attr.test; if (Array.isArray(value)) { return value.every(v => regex.test(v as string)); } return regex.test(value as string); } return true; } } linagora-ldap-rest-16e557e/src/abstract/plugin.ts000066400000000000000000000061351522642357000220150ustar00rootroot00000000000000/** * Abstract class for plugins * @author Xavier Guimard */ import type { Express } from 'express'; import type winston from 'winston'; import type { Config, DM } from '../bin'; import type { Hooks, MaybePromise } from '../hooks'; export { asyncHandler, escapeDnValue, unescapeDnValue, escapeLdapFilter, validateDnValue, } from '../lib/utils'; export { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, UriTooLongError, TooManyRequestsError, BadGatewayError, ServiceUnavailableError, GatewayTimeoutError, } from '../lib/errors'; export type Role = | 'auth' | 'authz' | 'protect' | 'api' | 'logging' | 'demo' | 'consistency' | 'configurable'; export default abstract class DmPlugin { /** * Properties inherited from parent (DM) */ /* parent object (DM server) */ server: DM; /* Global configuration */ config: Config; /* Logger */ logger: winston.Logger; /* Hooks registered into DM */ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type registeredHooks: { [K in keyof Hooks]?: Function[] } = {}; /** * Interfaces */ /* Hooks to register */ hooks?: Hooks; /* Needed plugins */ dependencies?: Record; /* Plugin roles for categorization */ roles?: Role[] | undefined; /* Function to register API */ api?(app: Express): MaybePromise; /* Function to provide configuration for config API */ getConfigApiData?(): Record | undefined; /* Uniq name of this plugin */ abstract name: string; /** * Constructor * @param server DM object */ constructor(server: DM) { this.server = server; this.config = server.config; this.logger = server.logger; this.registeredHooks = server.hooks; } /** * Uniq ID, to be used when calling hooks * * Example: plugin/ldap/groups has 2 events for some operations, * it uses opNumber to permit to plugin to link the 2 calls * * @returns uniq operation number */ opNumber(): number { return this.server.operationSequence++; } /** Names already warned about by requirePlugin, so each warns at most once. */ private missingPluginsWarned?: Set; /** * Resolve a sibling plugin by its registry name (the value of its `name` * property). Declare it in `dependencies` so it loads first. Returns null and * logs a single warning if it is absent, letting the caller no-op cleanly * rather than throw. Use this instead of indexing `server.loadedPlugins` * directly so consumers share one typed, log-once lookup. */ protected requirePlugin(name: string): T | null { const plugin = this.server.loadedPlugins[name] as T | undefined; if (plugin) return plugin; if (!this.missingPluginsWarned) this.missingPluginsWarned = new Set(); if (!this.missingPluginsWarned.has(name)) { this.missingPluginsWarned.add(name); this.logger.warn( `${this.name}: required plugin '${name}' is not loaded — its features will be skipped` ); } return null; } } linagora-ldap-rest-16e557e/src/abstract/twakePlugin.ts000066400000000000000000000204521522642357000230070ustar00rootroot00000000000000/** * Abstract base class for Twake integration plugins * Provides common HTTP request handling, concurrency control, and LDAP utilities * @author Xavier Guimard */ import fetch from 'node-fetch'; import pLimit from 'p-limit'; import type { DM } from '../bin'; import type { AttributesList, SearchResult } from '../lib/ldapActions'; import DmPlugin, { type Role } from './plugin'; /** * Abstract base class for all Twake-related plugins (James, Calendar, Matrix, etc.) * Consolidates common patterns for HTTP communication with Twake WebAdmin APIs */ export abstract class TwakePlugin extends DmPlugin { // Abstract properties - must be implemented by subclasses abstract name: string; abstract roles: Role[]; // Protected properties for subclass use protected webadminUrl: string; protected webadminToken: string; protected requestLimit: ReturnType; // Cached LDAP attribute names (commonly used across Twake plugins) protected mailAttr: string; protected displayNameAttr: string; /** * Constructor - initializes common Twake plugin configuration * @param server DM server instance * @param urlConfig Configuration key for WebAdmin URL * @param tokenConfig Configuration key for WebAdmin token * @param concurrencyConfig Configuration key for concurrency limit */ constructor( server: DM, urlConfig: string, tokenConfig: string, concurrencyConfig: string ) { super(server); // Initialize HTTP client configuration this.webadminUrl = (this.config[urlConfig] as string) || ''; this.webadminToken = (this.config[tokenConfig] as string) || ''; // Validate required configuration if (!this.webadminUrl) { throw new Error(`Twake plugin: ${urlConfig} is required`); } // Warn if authentication token is not configured if (!this.webadminToken) { this.logger.warn( `Twake plugin: No authentication token configured (${tokenConfig}) - requests will be unauthenticated` ); } // Initialize concurrency limiter const concurrency = typeof this.config[concurrencyConfig] === 'number' ? this.config[concurrencyConfig] : 10; this.requestLimit = pLimit(concurrency); this.logger.info( `Twake plugin HTTP request concurrency limit set to ${concurrency}` ); // Cache common LDAP attributes this.mailAttr = (this.config.mail_attribute as string) || 'mail'; this.displayNameAttr = (this.config.display_name_attribute as string) || 'displayName'; } /** * Create HTTP headers with optional Authorization token * @param contentType Optional Content-Type header value * @returns Headers object */ protected createHeaders(contentType?: string): { Authorization?: string; 'Content-Type'?: string; } { const headers: { Authorization?: string; 'Content-Type'?: string } = {}; if (this.webadminToken) { headers.Authorization = `Bearer ${this.webadminToken}`; } if (contentType) { headers['Content-Type'] = contentType; } return headers; } /** * Call Twake WebAdmin API with concurrency control and error handling * @param hookname Hook name for logging * @param url Full URL to call * @param method HTTP method * @param dn LDAP DN for logging * @param body Request body (string or null) * @param fields Additional fields for logging */ protected async callWebAdminApi( hookname: string, url: string, method: string, dn: string, body: string | null, fields: object ): Promise { return this.requestLimit(async () => { const log = { plugin: this.name, event: hookname, result: 'error', dn, ...fields, }; try { const opts: { method: string; body?: string | null; headers: { 'Content-Type'?: string; Authorization?: string; }; } = { method, headers: this.createHeaders(body ? 'application/json' : undefined), }; if (body) { opts.body = body; } const res = await fetch(url, opts); if (!res.ok) { // Allow subclasses to customize error handling if (this.shouldIgnoreError(res.status, hookname)) { this.logger.debug({ ...log, result: 'ignored', http_status: res.status, http_status_text: res.statusText, url, }); } else { this.logger.error({ ...log, http_status: res.status, http_status_text: res.statusText, url, }); } } else { this.logger.info({ ...log, result: 'success', http_status: res.status, url, }); } } catch (err) { this.logger.error({ ...log, error: err, url, }); } }); } /** * Override this method to customize error handling for specific status codes * @param statusCode HTTP status code * @param hookname Hook name that triggered the error * @returns true if error should be ignored */ protected shouldIgnoreError(statusCode: number, hookname: string): boolean { // Default: 409 Conflict is acceptable for some operations (e.g., alias already exists) return statusCode === 409 && hookname.includes('Alias'); } /** * Helper to convert LDAP attribute value to string * @param value LDAP attribute value (string, Buffer, array, etc.) * @returns String value or null */ protected attributeToString(value: unknown): string | null { if (!value) return null; if (Array.isArray(value)) { return value.length > 0 ? String(value[0]) : null; } return String(value as string | Buffer); } /** * Generic LDAP search utility to fetch specific attributes from a DN * Uses cache automatically for base-scope searches * @param dn The DN to fetch attributes from * @param attributes Optional array of attribute names to fetch * @returns AttributesList or null if not found */ protected async ldapGetAttributes( dn: string, attributes?: string[] ): Promise { try { const searchAttrs = attributes && attributes.length > 0 ? attributes : undefined; const result = (await this.server.ldap.search( { paged: false, scope: 'base', attributes: searchAttrs }, dn )) as SearchResult; if (result.searchEntries && result.searchEntries.length > 0) { return result.searchEntries[0] as AttributesList; } return null; } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions this.logger.debug(`Could not fetch attributes from DN ${dn}: ${err}`); return null; } } /** * Extract aliases from LDAP attribute value (handles AD format smtp:alias@domain.com) * @param value LDAP attribute value * @returns Array of normalized aliases */ protected getAliases( value: string | string[] | Buffer | Buffer[] | undefined ): string[] { 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 => this.normalizeAlias(a)); } /** * Normalize email alias - handle AD format (smtp:alias@domain.com) * @param alias Email alias * @returns Normalized alias */ protected normalizeAlias(alias: string): string { if (alias.toLowerCase().startsWith('smtp:')) { return alias.substring(5); } return alias; } /** * Extract domain from email address * @param email Email address * @returns Domain name or null */ protected extractMailDomain(email: string): string | null { const parts = email.split('@'); return parts.length === 2 ? parts[1] : null; } /** * Extract domain from LDAP DN (dc=example,dc=com -> example.com) * @param dn LDAP DN * @returns Domain name */ protected extractDomainFromDn(dn: string): string { const dcMatches = dn.match(/dc=([^,]+)/gi); if (dcMatches) { return dcMatches.map(dc => dc.substring(3)).join('.'); } return 'example.com'; } } export default TwakePlugin; linagora-ldap-rest-16e557e/src/bin/000077500000000000000000000000001522642357000171075ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/bin/index.ts000066400000000000000000000273421522642357000205760ustar00rootroot00000000000000/** * @packageDocumentation ldap-rest * @author Xavier Guimard * * Main server file * It loads plugins, setup express app,... and start the server * * @example * const server = new DM(); * * await server.ready; * await server.run(); */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-call */ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import express from 'express'; import bodyParser from 'body-parser'; import type winston from 'winston'; import type { Request, Response, NextFunction } from 'express'; import { parseConfig } from '../lib/parseConfig'; import configArgs, { type Config } from '../config/args'; import type { Hooks } from '../hooks'; import ldapActions from '../lib/ldapActions'; import type DmPlugin from '../abstract/plugin'; import { buildLogger } from '../logger/winston'; import { setLogger } from '../lib/expressFormatedResponses'; import pluginPriority from '../plugins/priority.json'; export type { Config }; // Internal Express router structure (used to remove error middleware) interface ExpressAppInternal { _router?: { stack: Array<{ handle: unknown }>; }; } export * from '../lib/utils'; export { asyncHandler } from '../lib/utils'; export { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, UriTooLongError, TooManyRequestsError, BadGatewayError, ServiceUnavailableError, GatewayTimeoutError, } from '../lib/errors'; //export const build = () => { /** * @class DM */ export class DM { app: express.Express; config: Config; ready: Promise; server?: import('http').Server; // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type hooks: { [K in keyof Hooks]?: Function[] } = {}; loadedPlugins: { [key: string]: DmPlugin } = {}; ldap: ldapActions; operationSequence: number; logger: winston.Logger; private _errorMiddlewareSetup: boolean = false; private _errorMiddleware?: express.ErrorRequestHandler; constructor() { this.config = parseConfig(configArgs); this.app = express(); this.app.use(bodyParser.json()); this.app.use(bodyParser.urlencoded({ extended: true })); this.logger = buildLogger(this.config); this.ldap = new ldapActions(this); setLogger(this.logger); this.operationSequence = 0; const promises: Promise[] = []; if (this.config.plugin) { // Separate configApi from other plugins const configApiPlugin = this.config.plugin.find(p => p.includes('configApi') ); let regularPlugins = this.config.plugin.filter( p => !p.includes('configApi') ); // Load priority plugins first sequentially to ensure proper middleware order const priorityPromise = (async () => { for (const p of pluginPriority) { if (regularPlugins.includes(p)) { regularPlugins = regularPlugins.filter(pl => pl !== p); await this.loadPlugin(p); } } })(); promises.push(priorityPromise); // Load remaining plugins in parallel after priority plugins promises.push( priorityPromise.then(async () => { const regularPromises = regularPlugins.map(pluginName => this.loadPlugin(pluginName) ); await Promise.all(regularPromises); }) ); // Load configApi last if (configApiPlugin) { promises.push( Promise.all(promises).then(() => this.loadPlugin(configApiPlugin)) ); } } this.ready = new Promise((resolve, reject) => { if (promises.length > 0) { Promise.all(promises) .then(() => { this.setupErrorMiddleware(); resolve(); }) .catch(err => reject(new Error('Error loading plugins: ' + err))); } else { this.setupErrorMiddleware(); resolve(); } }); } setupErrorMiddleware(): void { // Remove existing error middleware if already set up if (this._errorMiddlewareSetup && this._errorMiddleware) { const stack = (this.app as unknown as ExpressAppInternal)._router?.stack; if (stack) { const index = stack.findIndex( layer => layer.handle === this._errorMiddleware ); if (index !== -1) { stack.splice(index, 1); } } } // Create error handling middleware - must be after all routes this._errorMiddleware = ( err: Error, req: Request, res: Response, _next: NextFunction ) => { let statusCode = 'statusCode' in err ? (err as { statusCode: number }).statusCode : 500; // Recognise the authz-forbidden marker embedded by authz plugins so a // 403 survives being wrapped into a plain Error by downstream callers // (which otherwise drops `statusCode` and surfaces as a 500). let clientMessage = err.message; if (statusCode === 500 && /\[authz-forbidden\]/.test(err.message)) { statusCode = 403; clientMessage = 'Token does not have permission on this branch'; } else if ( statusCode === 403 && /\[authz-forbidden\]/.test(err.message) ) { clientMessage = 'Token does not have permission on this branch'; } // Client error (4xx) - log as warning and return error message if (statusCode >= 400 && statusCode < 500) { this.logger.warn( `Client error ${statusCode} in request ${req.method} ${req.path}: ${clientMessage}` ); if (!res.headersSent) { return res.status(statusCode).json({ error: clientMessage }); } return; } // Server error (5xx) - log as error and hide details this.logger.error( `Server error ${statusCode} in request ${req.method} ${req.path}: ${err.message}`, { stack: err.stack, url: req.url, method: req.method, } ); if (!res.headersSent) { res.status(statusCode).json({ error: 'Internal Server Error', message: this.config.debug ? err.message : 'An error occurred', }); } }; this.app.use(this._errorMiddleware); this._errorMiddlewareSetup = true; } run(): Promise { // Handle uncaught exceptions process.on('uncaughtException', (err: Error) => { this.logger.error(`Uncaught exception: ${err.message}`, { stack: err.stack, }); // Don't exit the process, just log the error }); // Handle unhandled promise rejections process.on( 'unhandledRejection', (reason: unknown, promise: Promise) => { this.logger.error(`Unhandled promise rejection: ${reason as string}`, { reason, promise, }); // Don't exit the process, just log the error } ); return new Promise((resolve, reject) => { this.server = this.app.listen(this.config.port, err => { if (err) { this.logger.error(`Error starting server: ${err}`); reject(err); } else { this.logger.debug(`Server started on port ${this.config.port}`); resolve(); } }); }); } stop(): void { this.app.removeAllListeners(); this.server?.close(); this.logger.debug('Server stopped'); } loadPlugin(pluginName: string): Promise { let name: string | undefined; let overrides: Config | undefined; if (/:/.test(pluginName)) { let tmp: string = pluginName.substring(pluginName.indexOf(':') + 1); pluginName = pluginName.substring(0, pluginName.indexOf(':')); if (/:/.test(tmp)) { name = tmp.substring(0, tmp.indexOf(':')); if (!name) name = undefined; tmp = tmp.substring(tmp.indexOf(':') + 1); try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment overrides = JSON.parse(tmp); if (typeof overrides !== 'object') { this.logger.error( `Overrides for plugin ${pluginName} are not valid: ${tmp}` ); overrides = undefined; } else { this.logger.debug( `Overrides for plugin ${name || pluginName}: ${tmp}` ); } } catch (err) { this.logger.error( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Failed to parse overrides for plugin ${pluginName}: ${err}, using ${tmp}` ); overrides = undefined; } } else { name = tmp; if (!name) name = undefined; } } else { name = undefined; } this.logger.debug(`Loading plugin ${pluginName}`); if (pluginName.startsWith('core/')) { pluginName = pluginName .replace( 'core/', join(dirname(fileURLToPath(import.meta.url)), '..', 'plugins') + '/' ) .replace(/$/, '.js'); } return new Promise((resolve, reject) => { import(pluginName) .then(async pluginModule => { if (pluginModule && pluginModule.default) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment pluginModule = pluginModule.default; } let obj; if (overrides) { const newConfig = { ...this.config, ...overrides }; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment obj = new pluginModule({ ...this, config: newConfig } as DM); } else { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment obj = new pluginModule(this); } if (!obj) return reject(new Error(`Unable to load ${pluginName}`)); resolve(await this.registerPlugin(pluginName, obj as DmPlugin, name)); this.logger.debug(`Plugin ${obj.name} loaded`); }) .catch(err => reject(new Error(`Failed to load plugin ${pluginName}: ${err}`)) ); }); } async registerPlugin( pluginName: string, obj: DmPlugin, name?: string ): Promise { if (!obj.name) obj.name = pluginName; if (name) obj.name = name; if (this.loadedPlugins[obj.name]) { this.logger.info(`Plugin ${pluginName} already loaded as ${obj.name}`); return false; } this.logger.debug(`Registering plugin ${pluginName} as ${obj.name}`); if (obj.dependencies) { for (const dependency in obj.dependencies) { if (!this.loadedPlugins[dependency]) { this.logger.debug( `Plugin ${obj.name} depends on ${dependency}, loading it first` ); await this.loadPlugin(obj.dependencies[dependency]); } } } if (obj.api) { this.logger.debug(`Plugin ${obj.name} has API, registering it`); await obj.api(this.app); // If error middleware was already setup, re-setup it to ensure it stays at the end if (this._errorMiddlewareSetup) { this.setupErrorMiddleware(); } } if (obj.hooks as Hooks) { for (const hookName in obj.hooks as Hooks) { this.logger.debug( `Plugin ${obj.name} has hook ${hookName}, registering it` ); const hook = (obj.hooks as Hooks)[hookName as keyof Hooks]; if (!this.hooks[hookName as keyof Hooks]) { this.hooks[hookName as keyof Hooks] = []; } if (hook && typeof hook === 'function') { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: object is defined this.hooks[hookName as keyof Hooks].push(hook); } else { throw new Error(`Plugin ${obj.name}: hook ${hookName} is invalid`); } } } this.loadedPlugins[obj.name] = obj; return true; } } linagora-ldap-rest-16e557e/src/browser/000077500000000000000000000000001522642357000200225ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-group-editor/000077500000000000000000000000001522642357000233605ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-group-editor/LdapGroupEditor.ts000066400000000000000000000165261522642357000270060ustar00rootroot00000000000000/** * LDAP Group Editor - Main class for editing group properties * @author Xavier Guimard */ import type { GroupEditorOptions, Config } from './types'; import { GroupApiClient } from './api/GroupApiClient'; import { GroupTree } from './components/GroupTree'; import { GroupPropertyEditor } from './components/GroupPropertyEditor'; import { CreateGroupModal } from './components/CreateGroupModal'; export class LdapGroupEditor { private options: GroupEditorOptions; private api: GroupApiClient; private container: HTMLElement | null = null; private groupTree: GroupTree | null = null; private groupEditor: GroupPropertyEditor | null = null; private currentGroupDn: string | null = null; private currentOrgDn: string | null = null; private config: Config | null = null; constructor(options: GroupEditorOptions) { this.options = { apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '', ...options, }; this.api = new GroupApiClient(this.options.apiBaseUrl || ''); } async init(): Promise { this.container = document.getElementById(this.options.containerId); if (!this.container) { throw new Error(`Container #${this.options.containerId} not found`); } // Load config first this.config = await this.api.getConfig(); this.render(); await this.initComponents(); } private render(): void { if (!this.container) return; this.container.innerHTML = `
group

Select a group from the tree to edit its properties

`; } private async initComponents(): Promise { const treeContainer = document.getElementById('group-tree-container'); if (!treeContainer) return; this.groupTree = new GroupTree( treeContainer, this.options.apiBaseUrl || window.location.origin, orgDn => this.onOrgSelected(orgDn) ); try { await this.groupTree.init(); } catch (error) { this.handleError(error as Error); } } private async onOrgSelected(orgDn: string): Promise { const editorContainer = document.getElementById('group-editor-container'); if (!editorContainer) return; this.currentOrgDn = orgDn; try { // Load groups for this organization const groups = await this.api.getGroups(orgDn); // Show list of groups with create button editorContainer.innerHTML = `

Groups

${ groups.length === 0 ? `
group

No groups in this organization

` : `
${groups .map( group => `
group ${this.escapeHtml(group.cn || group.dn)}
` ) .join('')}
` }
`; // Attach click handlers for group items editorContainer.querySelectorAll('.group-item').forEach(item => { item.addEventListener('click', () => { const groupDn = (item as HTMLElement).dataset.dn!; this.onGroupSelected(groupDn); }); }); // Attach click handler for create button const createBtn = editorContainer.querySelector('#create-group-btn'); if (createBtn) { createBtn.addEventListener('click', () => this.handleCreateGroup()); } } catch (error) { this.handleError(error as Error); editorContainer.innerHTML = `
error

Failed to load groups

`; } } private async onGroupSelected(groupDn: string): Promise { const editorContainer = document.getElementById('group-editor-container'); if (!editorContainer) return; this.currentGroupDn = groupDn; try { // Show group property editor this.groupEditor = new GroupPropertyEditor( editorContainer, this.api, groupDn ); await this.groupEditor.init(); // Notify parent if (this.options.onGroupSaved) { this.groupEditor.onSave(() => { this.options.onGroupSaved?.(groupDn); }); } } catch (error) { this.handleError(error as Error); editorContainer.innerHTML = `
error

Failed to load group properties

`; } } private async handleCreateGroup(): Promise { if (!this.currentOrgDn || !this.config) return; const modal = new CreateGroupModal( this.api, this.config, this.currentOrgDn, async () => { // Refresh the group list after creation await this.onOrgSelected(this.currentOrgDn!); if (this.options.onGroupSaved) { this.options.onGroupSaved(''); } } ); await modal.show(); } private escapeHtml(text: string | null | undefined): string { if (text == null) return ''; const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', }; return String(text).replace(/[&<>"'/]/g, m => map[m]); } private handleError(error: Error): void { console.error('LdapGroupEditor error:', error); if (this.options.onError) { this.options.onError(error); } } // Public API getApi(): GroupApiClient { return this.api; } getConfig(): Config | null { return this.config; } getCurrentOrgDn(): string | null { return this.currentOrgDn; } getCurrentUserDn(): string | null { // For compatibility with HTML that calls getCurrentUserDn return this.currentGroupDn; } async createUser(data: Record): Promise { // For compatibility - actually creates a group return this.api.createEntry(data.dn as string, data); } async deleteUser(dn: string): Promise { // For compatibility - actually deletes a group return this.api.deleteEntry(dn); } } linagora-ldap-rest-16e557e/src/browser/ldap-group-editor/api/000077500000000000000000000000001522642357000241315ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-group-editor/api/GroupApiClient.ts000066400000000000000000000105021522642357000273640ustar00rootroot00000000000000/** * API Client for LDAP Group operations */ import type { Config, LdapGroup } from '../types'; export class GroupApiClient { private baseUrl: string; constructor(baseUrl: string) { this.baseUrl = baseUrl; } async getConfig(): Promise { const response = await fetch(`${this.baseUrl}/api/v1/config`); if (!response.ok) { throw new Error(`Failed to load config: ${response.statusText}`); } return response.json(); } async getOrganizations(): Promise<{ dn: string }> { const response = await fetch( `${this.baseUrl}/api/v1/ldap/organizations/top` ); if (!response.ok) { throw new Error(`Failed to load organizations: ${response.statusText}`); } return response.json(); } async getGroups(orgDn: string): Promise { const response = await fetch( `${this.baseUrl}/api/v1/ldap/organizations/${encodeURIComponent(orgDn)}/subnodes?objectClass=groupOfNames` ); if (!response.ok) { throw new Error(`Failed to load groups: ${response.statusText}`); } const subnodes = await response.json(); // Filter to keep only groups return subnodes.filter((node: any) => { const classes = Array.isArray(node.objectClass) ? node.objectClass : [node.objectClass]; return classes.includes('groupOfNames') || classes.includes('group'); }); } async getGroup(dn: string): Promise { const response = await fetch( `${this.baseUrl}/api/v1/ldap/groups/${encodeURIComponent(dn)}` ); if (!response.ok) { throw new Error(`Failed to load group: ${response.statusText}`); } return response.json(); } async updateGroup(dn: string, data: Partial): Promise { const response = await fetch( `${this.baseUrl}/api/v1/ldap/groups/${encodeURIComponent(dn)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), } ); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to update group: ${error}`); } } async createEntry(dn: string, data: Record): Promise { // Use the groups API for creating groups const response = await fetch(`${this.baseUrl}/api/v1/ldap/groups`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to create entry: ${error}`); } } async deleteEntry(dn: string): Promise { // Use the groups API for deleting groups const response = await fetch( `${this.baseUrl}/api/v1/ldap/groups/${encodeURIComponent(dn)}`, { method: 'DELETE', } ); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to delete entry: ${error}`); } } async moveGroup(cn: string, targetOrgDn: string): Promise { const response = await fetch( `${this.baseUrl}/api/v1/ldap/groups/${encodeURIComponent(cn)}/move`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetOrgDn }), } ); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to move group: ${error}`); } } async renameGroup(cn: string, newCn: string): Promise { const response = await fetch( `${this.baseUrl}/api/v1/ldap/groups/${encodeURIComponent(cn)}/rename`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ newCn }), } ); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to rename group: ${error}`); } } async getPointerOptions( branch: string ): Promise> { const response = await fetch( `${this.baseUrl}/api/v1/ldap/search?base=${encodeURIComponent(branch)}&scope=one` ); if (!response.ok) { throw new Error(`Failed to load pointer options: ${response.statusText}`); } const entries = await response.json(); return entries.map((entry: any) => ({ dn: entry.dn, label: entry.cn?.[0] || entry.ou?.[0] || entry.dn, })); } } linagora-ldap-rest-16e557e/src/browser/ldap-group-editor/components/000077500000000000000000000000001522642357000255455ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-group-editor/components/CreateGroupModal.ts000066400000000000000000000204651522642357000313210ustar00rootroot00000000000000/** * Create Group Modal Component - Modal for creating a new group */ import type { GroupApiClient } from '../api/GroupApiClient'; import type { Config } from '../types'; export class CreateGroupModal { private api: GroupApiClient; private config: Config; private orgDn: string; private onCreated: () => Promise; private modal: HTMLElement | null = null; constructor( api: GroupApiClient, config: Config, orgDn: string, onCreated: () => Promise ) { this.api = api; this.config = config; this.orgDn = orgDn; this.onCreated = onCreated; } async show(): Promise { // Create modal backdrop this.modal = document.createElement('div'); this.modal.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; `; // Get organization path for the new group const orgPath = await this.getOrgPath(this.orgDn); // Create modal content const modalContent = document.createElement('div'); modalContent.style.cssText = ` background: white; border-radius: 8px; padding: 24px; max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); `; modalContent.innerHTML = `

group_add Create New Group

Must start with a letter, contain only letters, numbers, and hyphens

Organization:

${this.escapeHtml(orgPath || this.orgDn)}

`; this.modal.appendChild(modalContent); document.body.appendChild(this.modal); // Attach event listeners const cancelBtn = this.modal.querySelector('#cancel-create-btn'); if (cancelBtn) { cancelBtn.addEventListener('click', () => this.close()); } const form = this.modal.querySelector('#create-group-form'); if (form) { form.addEventListener('submit', e => this.handleSubmit(e)); } // Click outside to close this.modal.addEventListener('click', e => { if (e.target === this.modal) { this.close(); } }); } private async getOrgPath(orgDn: string): Promise { try { const response = await fetch( `${this.api['baseUrl']}/api/v1/ldap/organizations/${encodeURIComponent(orgDn)}` ); if (!response.ok) return null; const org = await response.json(); const pathAttr = (this.config as any).features?.organizations ?.pathAttribute as string | undefined; if (pathAttr && org[pathAttr]) { return Array.isArray(org[pathAttr]) ? org[pathAttr][0] : org[pathAttr]; } // If no path attribute, use the DN as fallback (for root organization) return orgDn; } catch (error) { console.error('Failed to load organization path:', error); return null; } } private async handleSubmit(e: Event): Promise { e.preventDefault(); const cnInput = this.modal?.querySelector('#group-cn') as HTMLInputElement; const mailInput = this.modal?.querySelector( '#group-mail' ) as HTMLInputElement; const descInput = this.modal?.querySelector( '#group-description' ) as HTMLTextAreaElement; const errorDiv = this.modal?.querySelector( '#error-message' ) as HTMLDivElement; if (!cnInput) return; const cn = cnInput.value.trim(); if (!cn) { this.showError(errorDiv, 'Group name is required'); return; } try { // Get the group branch from config // const groupBranch = this.config.ldap_groups_branch as string; // const dn = `cn=${cn},${groupBranch}`; // Get organization path for the group const orgPath = await this.getOrgPath(this.orgDn); // Build group data - only send cn, the backend will add objectClass, member, and default attributes const groupData: Record = { cn: cn, }; // Add optional fields if (mailInput?.value.trim()) { groupData.mail = mailInput.value.trim(); } if (descInput?.value.trim()) { groupData.description = descInput.value.trim(); } // Add organization link and path (required for twake groups) const linkAttr = (this.config as any).features?.organizations ?.linkAttribute as string | undefined; const pathAttr = (this.config as any).features?.organizations ?.pathAttribute as string | undefined; if (linkAttr && pathAttr) { if (!orgPath) { this.showError(errorDiv, 'Failed to get organization path'); return; } groupData[linkAttr] = this.orgDn; groupData[pathAttr] = orgPath; } // Create the group using the proper API const response = await fetch( `${this.api['baseUrl']}/api/v1/ldap/groups`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(groupData), } ); if (!response.ok) { const error = await response.text(); throw new Error(error || 'Failed to create group'); } // Call success callback and close await this.onCreated(); this.close(); } catch (error) { console.error('Failed to create group:', error); this.showError(errorDiv, (error as Error).message); } } private showError(errorDiv: HTMLDivElement | null, message: string): void { if (errorDiv) { errorDiv.textContent = `Error: ${message}`; errorDiv.style.display = 'block'; } } private close(): void { if (this.modal) { document.body.removeChild(this.modal); this.modal = null; } } private escapeHtml(text: string | null | undefined): string { if (text == null) return ''; const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', }; return String(text).replace(/[&<>"'/]/g, m => map[m]); } } linagora-ldap-rest-16e557e/src/browser/ldap-group-editor/components/GroupPropertyEditor.ts000066400000000000000000000516731522642357000321410ustar00rootroot00000000000000/** * Group Property Editor Component - Edits properties of a selected group */ import type { GroupApiClient } from '../api/GroupApiClient'; import type { LdapGroup, SchemaDefinition } from '../types'; import { MoveGroupModal } from './MoveGroupModal'; export class GroupPropertyEditor { private container: HTMLElement; private api: GroupApiClient; private groupDn: string; private group: LdapGroup | null = null; private schema: SchemaDefinition | null = null; private saveCallback: (() => void) | null = null; constructor(container: HTMLElement, api: GroupApiClient, groupDn: string) { this.container = container; this.api = api; this.groupDn = groupDn; } async init(): Promise { try { await this.loadGroup(); await this.loadSchema(); this.render(); this.attachEventListeners(); } catch (error) { console.error('Failed to init GroupPropertyEditor:', error); throw error; } } private async loadGroup(): Promise { console.warn('[GroupPropertyEditor] Loading group:', this.groupDn); this.group = await this.api.getGroup(this.groupDn); console.warn('[GroupPropertyEditor] Group loaded:', this.group); } private async loadSchema(): Promise { console.warn('[GroupPropertyEditor] Loading schema...'); const config = await this.api.getConfig(); console.warn('[GroupPropertyEditor] Config loaded:', config); // First check in features.ldapGroups (from ldapGroups plugin) const groupsConfig = (config.features as any)?.ldapGroups; console.warn('[GroupPropertyEditor] Groups config:', groupsConfig); if (groupsConfig?.schemaUrl) { const response = await fetch(groupsConfig.schemaUrl); this.schema = await response.json(); console.warn( '[GroupPropertyEditor] Schema loaded from URL:', this.schema ); } else if (groupsConfig?.schema) { this.schema = groupsConfig.schema; console.warn( '[GroupPropertyEditor] Schema loaded from config:', this.schema ); } else { // Fallback: check in flatResources const groupsResource = config.features?.ldapFlatGeneric?.flatResources?.find( r => r.pluralName === 'groups' || r.name === 'groups' ); console.warn( '[GroupPropertyEditor] Groups resource (flatResources):', groupsResource ); if (groupsResource?.schemaUrl) { const response = await fetch(groupsResource.schemaUrl); this.schema = await response.json(); console.warn( '[GroupPropertyEditor] Schema loaded from URL (flatResources):', this.schema ); } else if (groupsResource?.schema) { this.schema = groupsResource.schema; console.warn( '[GroupPropertyEditor] Schema loaded from config (flatResources):', this.schema ); } else { // No schema configured - create a default schema from the group's attributes console.warn( '[GroupPropertyEditor] No schema configured, creating default schema from group attributes' ); this.schema = this.createDefaultSchema(); console.warn( '[GroupPropertyEditor] Default schema created:', this.schema ); } } } private createDefaultSchema(): SchemaDefinition { if (!this.group) { return { entity: { objectClass: [] }, attributes: {} }; } const attributes: Record = {}; // Create schema attributes from the group's actual attributes for (const [key, value] of Object.entries(this.group)) { if (key === 'dn' || key === '*' || key === 'objectClass') continue; const isArray = Array.isArray(value); attributes[key] = { type: isArray ? 'array' : 'string', required: false, fixed: key === 'dn', }; } return { entity: { objectClass: Array.isArray(this.group.objectClass) ? this.group.objectClass : [this.group.objectClass], }, attributes, }; } onSave(callback: () => void): void { this.saveCallback = callback; } private render(): void { if (!this.group) return; this.container.innerHTML = `

group Edit Group

${this.renderFields()}
`; } private renderFields(): string { if (!this.group || !this.schema) { return '
Loading...
'; } let html = ''; // Group fields by category const basicFields: [string, any][] = []; const memberFields: [string, any][] = []; const mailFields: [string, any][] = []; for (const [fieldName, attribute] of Object.entries( this.schema.attributes )) { if (attribute.fixed || fieldName === 'objectClass') continue; if (fieldName === 'member' || fieldName === 'owner') { memberFields.push([fieldName, attribute]); } else if (fieldName.includes('mail') || fieldName.includes('Mail')) { mailFields.push([fieldName, attribute]); } else { basicFields.push([fieldName, attribute]); } } // Basic Information if (basicFields.length > 0) { html += '
'; html += '
Basic Information
'; for (const [fieldName, attribute] of basicFields) { html += this.renderField(fieldName, attribute); } html += '
'; } // Email/Mailbox Settings if (mailFields.length > 0) { html += '
'; html += '
Email/Mailbox Settings
'; for (const [fieldName, attribute] of mailFields) { html += this.renderField(fieldName, attribute); } html += '
'; } // Members & Owners if (memberFields.length > 0) { html += '
'; html += '
Members & Owners
'; for (const [fieldName, attribute] of memberFields) { html += this.renderField(fieldName, attribute); } html += '
'; } return html; } private renderField(fieldName: string, attribute: any): string { const value = this.group?.[fieldName]; const label = this.getFieldLabel(fieldName); const required = attribute.required ? 'required' : ''; if (attribute.type === 'array') { const arrayValue = Array.isArray(value) ? value.join('\n') : ''; // Special handling for member/owner fields with add/delete buttons if (fieldName === 'member' || fieldName === 'owner') { return `
One DN per line. Type to search for users, or enter DN/email manually. Select a line and press Delete to remove.
`; } return `
Enter one value per line
`; } if (attribute.type === 'number' || attribute.type === 'integer') { return `
`; } // String field const type = fieldName.toLowerCase().includes('mail') ? 'email' : 'text'; return `
`; } private getFieldLabel(fieldName: string): string { const labelMap: Record = { cn: 'Group Name', description: 'Description', member: 'Members', owner: 'Owners', mail: 'Email Address', twakeMailboxType: 'Mailbox Type', }; return ( labelMap[fieldName] || fieldName .replace(/([A-Z])/g, ' $1') .replace(/_/g, ' ') .replace(/^./, str => str.toUpperCase()) ); } private attachEventListeners(): void { const form = document.getElementById('group-edit-form') as HTMLFormElement; if (form) { form.addEventListener('submit', e => this.handleSubmit(e)); } const deleteBtn = document.getElementById('delete-group-btn'); if (deleteBtn) { deleteBtn.addEventListener('click', () => this.handleDelete()); } const moveBtn = document.getElementById('move-group-btn'); if (moveBtn) { moveBtn.addEventListener('click', () => this.handleMove()); } // Attach listeners for member/owner add buttons const addMemberBtn = this.container.querySelector('.btn-add-member'); if (addMemberBtn) { addMemberBtn.addEventListener('click', () => this.handleAddMember('member') ); } const addOwnerBtn = this.container.querySelector('.btn-add-owner'); if (addOwnerBtn) { addOwnerBtn.addEventListener('click', () => this.handleAddMember('owner') ); } // Handle Delete key for removing members/owners ['member', 'owner'].forEach(fieldName => { const textarea = document.getElementById( `${fieldName}-list` ) as HTMLTextAreaElement; if (textarea) { textarea.addEventListener('keydown', e => { if (e.key === 'Delete') { this.handleDeleteMember(textarea, e); } }); } // Add autocomplete for member/owner inputs const input = document.getElementById( `${fieldName}-add-input` ) as HTMLInputElement; if (input) { let searchTimeout: number; input.addEventListener('input', () => { clearTimeout(searchTimeout); searchTimeout = window.setTimeout(() => { this.searchUsers(input.value, fieldName); }, 300); }); } }); } private async searchUsers(query: string, fieldName: string): Promise { if (!query || query.length < 2) return; try { // Search for users by CN, UID, or mail const response = await fetch( `${this.api['baseUrl']}/api/v1/ldap/search?filter=(|(cn=*${encodeURIComponent(query)}*)(uid=*${encodeURIComponent(query)}*)(mail=*${encodeURIComponent(query)}*))` ); if (!response.ok) return; const results = await response.json(); const datalist = document.getElementById( `${fieldName}-suggestions` ) as HTMLDataListElement; if (!datalist) return; datalist.innerHTML = results .slice(0, 10) .map((user: any) => { const label = user.cn?.[0] || user.uid?.[0] || user.mail?.[0] || user.dn; return ``; }) .join(''); } catch (error) { console.error('Failed to search users:', error); } } private async handleAddMember(fieldName: 'member' | 'owner'): Promise { const input = document.getElementById( `${fieldName}-add-input` ) as HTMLInputElement; const textarea = document.getElementById( `${fieldName}-list` ) as HTMLTextAreaElement; if (!input || !textarea) return; const valueToAdd = input.value.trim(); if (!valueToAdd) { alert('Please enter a DN or email address'); return; } // Get current members/owners const currentValues = textarea.value .split('\n') .map(v => v.trim()) .filter(v => v); // Check if already exists if (currentValues.includes(valueToAdd)) { alert(`This ${fieldName} already exists`); return; } // Add to list currentValues.push(valueToAdd); textarea.value = currentValues.join('\n'); // Clear input input.value = ''; input.focus(); } private handleDeleteMember( textarea: HTMLTextAreaElement, e: KeyboardEvent ): void { const start = textarea.selectionStart; const end = textarea.selectionEnd; const value = textarea.value; // Find the line(s) being selected const lines = value.split('\n'); let currentPos = 0; let startLine = 0; let endLine = 0; for (let i = 0; i < lines.length; i++) { const lineLength = lines[i].length + 1; // +1 for newline if (currentPos <= start && start < currentPos + lineLength) { startLine = i; } if (currentPos <= end && end <= currentPos + lineLength) { endLine = i; break; } currentPos += lineLength; } // Remove selected lines e.preventDefault(); const newLines = lines.filter((_, i) => i < startLine || i > endLine); textarea.value = newLines.join('\n'); } private async handleSubmit(e: Event): Promise { e.preventDefault(); const form = e.target as HTMLFormElement; const formData = new FormData(form); const updates: Record = {}; for (const [key, value] of (formData as any).entries()) { if (this.schema?.attributes[key]?.type === 'array') { updates[key] = (value as string) .split('\n') .map(v => v.trim()) .filter(v => v); } else if ( this.schema?.attributes[key]?.type === 'number' || this.schema?.attributes[key]?.type === 'integer' ) { updates[key] = Number(value); } else { updates[key] = value; } } try { // Check if cn has changed - if so, use rename API instead if (updates.cn && this.group?.cn) { const oldCn = Array.isArray(this.group.cn) ? this.group.cn[0] : this.group.cn; const newCn = updates.cn as string; if (oldCn !== newCn) { // Remove cn from updates delete updates.cn; // First rename the group await this.api.renameGroup(this.groupDn, newCn); // Update the groupDn for subsequent operations const newDn = this.groupDn.replace( new RegExp(`^cn=${oldCn},`), `cn=${newCn},` ); this.groupDn = newDn; // If there are other updates, apply them to the renamed group if (Object.keys(updates).length > 0) { await this.api.updateGroup(this.groupDn, updates); } } else { // cn hasn't changed, just do a normal update await this.api.updateGroup(this.groupDn, updates); } } else { // No cn change, normal update await this.api.updateGroup(this.groupDn, updates); } alert('Group updated successfully!'); if (this.saveCallback) { this.saveCallback(); } } catch (error) { console.error('Failed to update group:', error); alert(`Failed to update group: ${(error as Error).message}`); } } private async handleMove(): Promise { if (!this.group) return; // Get current organization DN from twakeDepartmentLink attribute const currentOrgDn = (this.group as any).twakeDepartmentLink as | string | undefined; if (!currentOrgDn) { alert( 'Cannot move group: no organization link attribute found. This group may not support move operation.' ); return; } // Extract group cn from DN const cnMatch = this.groupDn.match(/^cn=([^,]+)/); const cn = cnMatch ? cnMatch[1] : this.groupDn; const modal = new MoveGroupModal( this.api, currentOrgDn, async targetOrgDn => { try { await this.api.moveGroup(cn, targetOrgDn); alert('Group moved successfully!'); // Reload the group await this.loadGroup(); this.render(); this.attachEventListeners(); if (this.saveCallback) { this.saveCallback(); } } catch (error) { console.error('Failed to move group:', error); alert(`Failed to move group: ${(error as Error).message}`); } } ); await modal.show(); } private async handleDelete(): Promise { if ( !confirm( `Are you sure you want to delete this group?\n\n${this.groupDn}\n\nThis action cannot be undone.` ) ) { return; } try { await this.api.deleteEntry(this.groupDn); alert('Group deleted successfully!'); this.container.innerHTML = `
group

Group deleted. Select another group to edit.

`; } catch (error) { console.error('Failed to delete group:', error); alert(`Failed to delete group: ${(error as Error).message}`); } } private escapeHtml(text: string | null | undefined): string { if (text == null) return ''; const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', }; return String(text).replace(/[&<>"'/]/g, m => map[m]); } } linagora-ldap-rest-16e557e/src/browser/ldap-group-editor/components/GroupTree.ts000066400000000000000000000124441522642357000300360ustar00rootroot00000000000000/** * Group Tree Component - Shows organizations tree (same as UserTree) */ export class GroupTree { private container: HTMLElement; private apiBaseUrl: string; private onOrgSelect: (orgDn: string) => void; private expandedNodes = new Set(); private selectedDn: string | null = null; private rootDn: string | null = null; constructor( container: HTMLElement, apiBaseUrl: string, onOrgSelect: (orgDn: string) => void ) { this.container = container; this.apiBaseUrl = apiBaseUrl; this.onOrgSelect = onOrgSelect; } async init(): Promise { this.container.innerHTML = `

account_tree Organizations

`; try { const response = await fetch( `${this.apiBaseUrl}/api/v1/ldap/organizations/top` ); if (!response.ok) throw new Error('Failed to load organizations'); const topOrg = await response.json(); this.rootDn = topOrg.dn; await this.renderTree(); } catch (error) { console.error('Failed to init tree:', error); const treeEl = this.container.querySelector('#tree-content'); if (treeEl) { treeEl.innerHTML = '

Failed to load organizations

'; } } } private async renderTree(): Promise { const treeEl = this.container.querySelector('#tree-content'); if (!treeEl || !this.rootDn) return; try { const html = await this.renderNode(this.rootDn, 0); treeEl.innerHTML = html; this.attachEventListeners(); } catch (error) { console.error('Failed to render tree:', error); treeEl.innerHTML = '

Error rendering tree

'; } } private async renderNode(dn: string, level: number): Promise { const isExpanded = this.expandedNodes.has(dn); const isSelected = this.selectedDn === dn; const indent = level * 20; // Get node info const response = await fetch( `${this.apiBaseUrl}/api/v1/ldap/organizations/${encodeURIComponent(dn)}` ); if (!response.ok) throw new Error('Failed to load node'); const node = await response.json(); // Extract display name let displayName = dn; if (node.ou) { displayName = Array.isArray(node.ou) ? node.ou[0] : node.ou; } else if (node.cn) { displayName = Array.isArray(node.cn) ? node.cn[0] : node.cn; } let html = `
${isExpanded ? 'expand_more' : 'chevron_right'} business ${this.escapeHtml(displayName)}
`; if (isExpanded) { try { const subnodesResponse = await fetch( `${this.apiBaseUrl}/api/v1/ldap/organizations/${encodeURIComponent(dn)}/subnodes` ); if (!subnodesResponse.ok) throw new Error('Failed to load subnodes'); const subnodes = await subnodesResponse.json(); const orgs = subnodes.filter((n: any) => { const classes = Array.isArray(n.objectClass) ? n.objectClass : n.objectClass ? [n.objectClass] : []; return ( classes.includes('organizationalUnit') || classes.includes('organization') ); }); for (const subnode of orgs) { html += await this.renderNode(subnode.dn, level + 1); } } catch (error) { console.error('Failed to load subnodes for', dn, error); } } return html; } private attachEventListeners(): void { // Toggle expand/collapse this.container.querySelectorAll('.tree-node-toggle').forEach(el => { el.addEventListener('click', async e => { e.stopPropagation(); const dn = el.getAttribute('data-dn'); if (!dn) return; if (this.expandedNodes.has(dn)) { this.expandedNodes.delete(dn); } else { this.expandedNodes.add(dn); } await this.renderTree(); }); }); // Select organization this.container.querySelectorAll('.tree-node-label').forEach(el => { el.addEventListener('click', e => { e.stopPropagation(); const dn = el.getAttribute('data-dn'); if (!dn) return; this.selectedDn = dn; this.onOrgSelect(dn); this.renderTree(); }); }); } private escapeHtml(text: string | null | undefined): string { if (text == null) return ''; const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', }; return String(text).replace(/[&<>"'/]/g, m => map[m]); } } linagora-ldap-rest-16e557e/src/browser/ldap-group-editor/components/MoveGroupModal.ts000066400000000000000000000216661522642357000310300ustar00rootroot00000000000000/** * Move Group Modal Component - Modal for selecting target organization when moving a group */ import type { GroupApiClient } from '../api/GroupApiClient'; export class MoveGroupModal { private api: GroupApiClient; private currentOrgDn: string; private onMove: (targetOrgDn: string) => Promise; private modal: HTMLElement | null = null; private expandedNodes = new Set(); private selectedOrgDn: string | null = null; constructor( api: GroupApiClient, currentOrgDn: string, onMove: (targetOrgDn: string) => Promise ) { this.api = api; this.currentOrgDn = currentOrgDn; this.onMove = onMove; } async show(): Promise { // Create modal backdrop this.modal = document.createElement('div'); this.modal.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; `; // Create modal content const modalContent = document.createElement('div'); modalContent.style.cssText = ` background: white; border-radius: 8px; padding: 24px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); `; modalContent.innerHTML = `

drive_file_move Move Group to Organization

Select the target organization:

`; this.modal.appendChild(modalContent); document.body.appendChild(this.modal); // Attach event listeners const cancelBtn = this.modal.querySelector('#cancel-move-btn'); if (cancelBtn) { cancelBtn.addEventListener('click', () => this.close()); } const confirmBtn = this.modal.querySelector('#confirm-move-btn'); if (confirmBtn) { confirmBtn.addEventListener('click', () => this.handleConfirm()); } // Click outside to close this.modal.addEventListener('click', e => { if (e.target === this.modal) { this.close(); } }); // Load organization tree await this.loadOrgTree(); } private async loadOrgTree(): Promise { const treeContainer = this.modal?.querySelector('#org-tree'); if (!treeContainer) return; try { // Get top organization const response = await fetch( `${this.api['baseUrl']}/api/v1/ldap/organizations/top` ); if (!response.ok) throw new Error('Failed to load organizations'); const topOrg = await response.json(); const rootDn = topOrg.dn; // Render tree starting from root const html = await this.renderNode(rootDn, 0); treeContainer.innerHTML = html; this.attachTreeEventListeners(); } catch (error) { console.error('Failed to load organization tree:', error); treeContainer.innerHTML = `

Failed to load organizations

`; } } private async renderNode(dn: string, level: number): Promise { const isExpanded = this.expandedNodes.has(dn); const isSelected = this.selectedOrgDn === dn; const isCurrent = this.currentOrgDn === dn; const indent = level * 20; // Get node info const response = await fetch( `${this.api['baseUrl']}/api/v1/ldap/organizations/${encodeURIComponent(dn)}` ); if (!response.ok) throw new Error('Failed to load node'); const node = await response.json(); // Extract display name let displayName = dn; if (node.ou) { displayName = Array.isArray(node.ou) ? node.ou[0] : node.ou; } else if (node.cn) { displayName = Array.isArray(node.cn) ? node.cn[0] : node.cn; } const nodeClass = `tree-node ${isSelected ? 'selected' : ''} ${isCurrent ? 'current-org' : ''}`; const nodeStyle = `padding-left: ${indent}px; padding: 8px 8px 8px ${indent}px; cursor: ${isCurrent ? 'not-allowed' : 'pointer'}; display: flex; align-items: center; gap: 4px; ${isCurrent ? 'opacity: 0.5; background: #f5f5f5;' : isSelected ? 'background: #e3f2fd;' : ''}`; let html = `
${isExpanded ? 'expand_more' : 'chevron_right'} business ${this.escapeHtml(displayName)} ${isCurrent ? '(current)' : ''}
`; // If expanded, load and render subnodes if (isExpanded) { try { const subnodesResponse = await fetch( `${this.api['baseUrl']}/api/v1/ldap/organizations/${encodeURIComponent(dn)}/subnodes` ); if (!subnodesResponse.ok) throw new Error('Failed to load subnodes'); const subnodes = await subnodesResponse.json(); const orgs = subnodes.filter((n: any) => { const classes = Array.isArray(n.objectClass) ? n.objectClass : n.objectClass ? [n.objectClass] : []; return ( classes.includes('organizationalUnit') || classes.includes('organization') ); }); for (const subnode of orgs) { html += await this.renderNode(subnode.dn, level + 1); } } catch (error) { console.error('Failed to load subnodes for', dn, error); } } return html; } private attachTreeEventListeners(): void { const treeContainer = this.modal?.querySelector('#org-tree'); if (!treeContainer) return; // Toggle expand/collapse treeContainer.querySelectorAll('.tree-node-toggle').forEach(el => { el.addEventListener('click', async e => { e.stopPropagation(); const dn = el.getAttribute('data-dn'); if (!dn) return; if (this.expandedNodes.has(dn)) { this.expandedNodes.delete(dn); } else { this.expandedNodes.add(dn); } await this.loadOrgTree(); }); }); // Select organization treeContainer.querySelectorAll('.tree-node').forEach(el => { el.addEventListener('click', () => { const isCurrent = el.getAttribute('data-is-current') === 'true'; if (isCurrent) return; // Cannot select current organization const dn = el.getAttribute('data-dn'); if (!dn) return; this.selectedOrgDn = dn; // Update UI treeContainer.querySelectorAll('.tree-node').forEach(node => { node.classList.remove('selected'); (node as HTMLElement).style.background = ''; }); el.classList.add('selected'); (el as HTMLElement).style.background = '#e3f2fd'; // Enable confirm button const confirmBtn = this.modal?.querySelector( '#confirm-move-btn' ) as HTMLButtonElement; if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.style.opacity = '1'; confirmBtn.style.cursor = 'pointer'; } }); }); } private async handleConfirm(): Promise { if (!this.selectedOrgDn) return; try { await this.onMove(this.selectedOrgDn); this.close(); } catch (error) { console.error('Failed to move group:', error); // Error will be shown by the parent component } } private close(): void { if (this.modal) { document.body.removeChild(this.modal); this.modal = null; } } private escapeHtml(text: string | null | undefined): string { if (text == null) return ''; const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', }; return String(text).replace(/[&<>"'/]/g, m => map[m]); } } linagora-ldap-rest-16e557e/src/browser/ldap-group-editor/index.ts000066400000000000000000000003071522642357000250370ustar00rootroot00000000000000/** * LDAP Group Editor - Entry point * @author Xavier Guimard */ export { LdapGroupEditor } from './LdapGroupEditor.js'; export type { GroupEditorOptions, Config, LdapGroup } from './types.js'; linagora-ldap-rest-16e557e/src/browser/ldap-group-editor/types.ts000066400000000000000000000023141522642357000250740ustar00rootroot00000000000000/** * TypeScript types for LDAP Group Editor */ export interface GroupEditorOptions { containerId: string; apiBaseUrl?: string; onGroupSaved?: (groupDn: string) => void; onError?: (error: Error) => void; } export interface Config { ldapBase: string; features?: { flatResources?: ResourceConfig[]; ldapFlatGeneric?: { flatResources?: ResourceConfig[]; }; ldapGroups?: { schemaUrl?: string; schema?: SchemaDefinition; }; }; [key: string]: unknown; } export interface ResourceConfig { name: string; pluralName?: string; schemaUrl?: string; schema?: SchemaDefinition; base?: string; } export interface SchemaDefinition { entity: { objectClass?: string[]; base?: string; }; attributes: Record; } export interface SchemaAttribute { type: 'string' | 'number' | 'integer' | 'array' | 'pointer'; required?: boolean; fixed?: boolean; default?: unknown; branch?: string[]; items?: { type: string; branch?: string[]; }; group?: string; } export interface LdapGroup { dn: string; cn: string; description?: string; member?: string[]; owner?: string[]; mail?: string; [key: string]: unknown; } linagora-ldap-rest-16e557e/src/browser/ldap-resource-editor/000077500000000000000000000000001522642357000240535ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-resource-editor/api/000077500000000000000000000000001522642357000246245ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-resource-editor/api/ResourceApiClient.ts000066400000000000000000000206671522642357000305670ustar00rootroot00000000000000/** * LDAP Resource Editor - Generic API Client */ import type { Config, LdapResource, PointerOption, Schema, ResourceType, } from '../types'; import { CacheManager } from '../cache/CacheManager'; export class ResourceApiClient { private baseUrl: string; private cache: CacheManager; private resourceType: ResourceType; constructor( resourceType: ResourceType, baseUrl: string = '', cacheOptions?: { ttl?: number; maxEntries?: number } ) { this.resourceType = resourceType; this.baseUrl = baseUrl || (typeof window !== 'undefined' ? window.location.origin : ''); this.cache = new CacheManager(cacheOptions); // Clean expired entries every 5 minutes (only in browser context) if (typeof window !== 'undefined') { window.setInterval(() => this.cache.cleanExpired(), 5 * 60 * 1000); } } private getApiBase(): string { return `${this.baseUrl}/api/v1`; } private getResourceEndpoint(): string { return `${this.getApiBase()}/ldap/${this.resourceType}`; } private getFirstValue(value: unknown): string { if (!value) return ''; if (Array.isArray(value)) return value[0] || ''; return String(value); } /** * Fetch with cache support for GET requests */ private async cachedFetch(url: string, options?: RequestInit): Promise { const method = options?.method || 'GET'; // Only cache GET requests if (method === 'GET') { const cached = this.cache.get(url); if (cached !== null) { return cached; } } const res = await fetch(url, options); if (!res.ok) { throw new Error(`HTTP ${res.status}: ${res.statusText}`); } const data = (await res.json()) as T; // Cache GET responses if (method === 'GET') { this.cache.set(url, data); } return data; } async getConfig(): Promise { return this.cachedFetch(`${this.getApiBase()}/config`); } async getSchema(schemaUrl: string): Promise { // Handle relative URLs by prepending baseUrl const url = schemaUrl.startsWith('http') ? schemaUrl : `${this.baseUrl}${schemaUrl}`; return this.cachedFetch(url); } async getResources(search = ''): Promise { const url = search ? `${this.getResourceEndpoint()}?match=${encodeURIComponent(search)}&attribute=${this.getMainAttribute()}` : this.getResourceEndpoint(); const data = await this.cachedFetch< LdapResource[] | Record >(url); // Convert from object format {key: entry} to array format return Array.isArray(data) ? data : Object.values(data); } async getResource(dn: string): Promise { const url = `${this.getResourceEndpoint()}/${encodeURIComponent(dn)}`; return this.cachedFetch(url); } async updateResource( dn: string, data: Partial ): Promise { const res = await fetch( `${this.getResourceEndpoint()}/${encodeURIComponent(dn)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), } ); if (!res.ok) { const error = await res.text(); throw new Error( `Failed to update ${this.resourceType}: ${error || 'Unknown error'}` ); } const result = await res.json(); // Invalidate cache this.cache.invalidate( `${this.getResourceEndpoint()}/${encodeURIComponent(dn)}` ); this.cache.invalidatePattern(`${this.getResourceEndpoint()}*`); return result; } async createResource(data: Partial): Promise { const res = await fetch(this.getResourceEndpoint(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!res.ok) { const error = await res.text(); throw new Error( `Failed to create ${this.resourceType}: ${error || 'Unknown error'}` ); } const result = await res.json(); // Invalidate cache this.cache.invalidatePattern(`${this.getResourceEndpoint()}*`); return result; } async deleteResource(dn: string): Promise { const res = await fetch( `${this.getResourceEndpoint()}/${encodeURIComponent(dn)}`, { method: 'DELETE', } ); if (!res.ok) { const error = await res.text(); throw new Error( `Failed to delete ${this.resourceType}: ${error || 'Unknown error'}` ); } // Invalidate cache this.cache.invalidate( `${this.getResourceEndpoint()}/${encodeURIComponent(dn)}` ); this.cache.invalidatePattern(`${this.getResourceEndpoint()}*`); } /** * Create a generic entry (for organizations tree navigation) */ async createEntry( dn: string, data: Partial ): Promise { const res = await fetch( `${this.getApiBase()}/ldap/entry/${encodeURIComponent(dn)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), } ); if (!res.ok) { const error = await res.text(); throw new Error(`Failed to create entry: ${error || 'Unknown error'}`); } const result = await res.json(); // Invalidate cache this.cache.invalidatePattern(`${this.getResourceEndpoint()}*`); this.cache.invalidatePattern(`${this.getApiBase()}/ldap/organizations*`); return result; } /** * Delete a generic entry (for organizations) */ async deleteEntry(dn: string): Promise { const res = await fetch( `${this.getApiBase()}/ldap/entry/${encodeURIComponent(dn)}`, { method: 'DELETE', } ); if (!res.ok) { const error = await res.text(); throw new Error(`Failed to delete entry: ${error || 'Unknown error'}`); } // Invalidate cache this.cache.invalidatePattern(`${this.getResourceEndpoint()}*`); this.cache.invalidatePattern(`${this.getApiBase()}/ldap/organizations*`); } async getPointerOptions(branch: string): Promise { try { const config = await this.getConfig(); const resource = config.features?.ldapFlatGeneric?.flatResources?.find( r => { return branch === r.base || branch.startsWith(r.base); } ); if (resource && resource.endpoints?.list) { const url = `${this.baseUrl}${resource.endpoints.list}`; const data = await this.cachedFetch< LdapResource[] | Record >(url); const items = Array.isArray(data) ? data : Object.values(data); let displayNameField = 'cn'; const identifierField = resource.mainAttribute; if (resource.schemaUrl) { try { const schema = await this.getSchema(resource.schemaUrl); const displayField = Object.entries(schema.attributes).find( ([, attr]) => attr.role === 'displayName' ); if (displayField) { displayNameField = displayField[0]; } // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { // Use defaults if schema fails } } return items.map((item: LdapResource) => { const displayName = this.getFirstValue(item[displayNameField]); const identifier = this.getFirstValue(item[identifierField]); return { dn: item.dn, label: displayName || identifier || item.dn, }; }); } throw new Error(`No flatResource found in config for branch: ${branch}`); } catch (error) { console.error( 'Failed to load pointer options for branch:', branch, error ); return []; } } /** * Get main attribute for this resource type */ private getMainAttribute(): string { switch (this.resourceType) { case 'users': return 'uid'; case 'groups': return 'cn'; case 'organizations': return 'ou'; default: return 'cn'; } } /** * Cache management methods */ clearCache(): void { this.cache.clear(); } invalidateCache(pattern: string): void { this.cache.invalidatePattern(pattern); } getCacheStats(): { size: number; maxSize: number; ttl: number; keys: string[]; } { return this.cache.getStats(); } getCache(): CacheManager { return this.cache; } } linagora-ldap-rest-16e557e/src/browser/ldap-resource-editor/cache/000077500000000000000000000000001522642357000251165ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-resource-editor/cache/CacheManager.ts000066400000000000000000000066361522642357000277770ustar00rootroot00000000000000/** * Simple in-memory cache manager with LRU eviction * Prevents excessive API calls when navigating the organization tree */ interface CacheEntry { data: T; timestamp: number; accessCount: number; } export interface CacheOptions { /** * Time to live in milliseconds (default: 5 minutes) */ ttl?: number; /** * Maximum number of entries (default: 200) */ maxEntries?: number; } export class CacheManager { private cache: Map> = new Map(); private ttl: number; private maxEntries: number; constructor(options: CacheOptions = {}) { this.ttl = options.ttl ?? 5 * 60 * 1000; // 5 minutes default this.maxEntries = options.maxEntries ?? 200; // 200 entries max } /** * Get an item from cache * Returns null if not found or expired */ get(key: string): T | null { const entry = this.cache.get(key) as CacheEntry | undefined; if (!entry) return null; // Check if expired if (Date.now() - entry.timestamp >= this.ttl) { this.cache.delete(key); return null; } // Update access count for LRU entry.accessCount++; entry.timestamp = Date.now(); return entry.data; } /** * Set an item in cache * Evicts least recently used items if cache is full */ set(key: string, data: T): void { // If cache is full, evict LRU entry if (this.cache.size >= this.maxEntries && !this.cache.has(key)) { this.evictLRU(); } this.cache.set(key, { data, timestamp: Date.now(), accessCount: 0, }); } /** * Check if key exists and is not expired */ has(key: string): boolean { return this.get(key) !== null; } /** * Invalidate a specific key */ invalidate(key: string): void { this.cache.delete(key); } /** * Invalidate all keys matching a pattern * Pattern can contain wildcards (*) */ invalidatePattern(pattern: string): void { const regex = new RegExp( '^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$' ); for (const key of this.cache.keys()) { if (regex.test(key)) { this.cache.delete(key); } } } /** * Clear all cache */ clear(): void { this.cache.clear(); } /** * Get cache statistics */ getStats(): { size: number; maxSize: number; ttl: number; keys: string[]; } { return { size: this.cache.size, maxSize: this.maxEntries, ttl: this.ttl, keys: Array.from(this.cache.keys()), }; } /** * Evict least recently used entry */ private evictLRU(): void { let lruKey: string | null = null; let lruTime = Infinity; let lruCount = Infinity; for (const [key, entry] of this.cache.entries()) { // Prioritize by access count, then by timestamp if ( entry.accessCount < lruCount || (entry.accessCount === lruCount && entry.timestamp < lruTime) ) { lruKey = key; lruTime = entry.timestamp; lruCount = entry.accessCount; } } if (lruKey) { this.cache.delete(lruKey); } } /** * Clean expired entries * Should be called periodically */ cleanExpired(): number { const now = Date.now(); let cleaned = 0; for (const [key, entry] of this.cache.entries()) { if (now - entry.timestamp >= this.ttl) { this.cache.delete(key); cleaned++; } } return cleaned; } } linagora-ldap-rest-16e557e/src/browser/ldap-resource-editor/index.ts000066400000000000000000000005031522642357000255300ustar00rootroot00000000000000/** * LDAP Resource Editor - Generic browser library * @author Xavier Guimard */ export { ResourceApiClient } from './api/ResourceApiClient'; export type { EditorOptions, LdapResource, Schema, SchemaAttribute, PointerOption, Config, ResourceType, NavigationType, ResourceTypeConfig, } from './types'; linagora-ldap-rest-16e557e/src/browser/ldap-resource-editor/types.ts000066400000000000000000000032521522642357000255710ustar00rootroot00000000000000/** * LDAP Resource Editor - Generic Type Definitions */ export type ResourceType = 'users' | 'groups' | 'organizations'; export type NavigationType = 'flat' | 'tree'; export interface EditorOptions { containerId: string; resourceType: ResourceType; apiBaseUrl?: string; navigationType?: NavigationType; onResourceSaved?: (resourceDn: string) => void; onError?: (error: Error) => void; } export interface LdapResource { dn: string; [key: string]: unknown; } export interface SchemaAttribute { type: string; required?: boolean; fixed?: boolean; role?: string; test?: string; branch?: string[]; items?: SchemaAttribute; default?: unknown; ui?: 'select' | 'search'; group?: string; } export interface Schema { entity: { name: string; mainAttribute: string; objectClass: string[]; singularName: string; pluralName: string; base: string; }; strict: boolean; attributes: Record; } export interface PointerOption { dn: string; label: string; } export interface FlatResourceConfig { name: string; singularName: string; pluralName: string; mainAttribute: string; objectClass: string[]; base: string; schema?: Schema; schemaUrl?: string; endpoints: { list: string; get: string; create: string; update: string; delete: string; }; } export interface Config { apiPrefix: string; ldapBase: string; features?: { flatResources?: FlatResourceConfig[]; ldapFlatGeneric?: { flatResources?: FlatResourceConfig[]; }; }; } export interface ResourceTypeConfig { singularName: string; pluralName: string; icon: string; emptyStateMessage: string; } linagora-ldap-rest-16e557e/src/browser/ldap-tree-viewer/000077500000000000000000000000001522642357000231765ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-tree-viewer/LdapTreeViewer.ts000066400000000000000000000150171522642357000264340ustar00rootroot00000000000000/** * Main LdapTreeViewer class */ import type { ViewerOptions, TreeNode, TreeState } from './types'; import { Store } from './store/Store'; import { treeReducer, initialState } from './store/reducers'; import { setRoot, addNode, updateNode, toggleExpanded, setLoading, setError, selectNode, } from './store/actions'; import { LdapApiClient } from './api/LdapApiClient'; import { TreeRoot } from './components/TreeRoot'; export class LdapTreeViewer { private store: Store; private api: LdapApiClient; private container: HTMLElement; private rootComponent: TreeRoot | null = null; private unsubscribe?: () => void; constructor(private options: ViewerOptions) { const container = document.getElementById(options.containerId); if (!container) { throw new Error(`Container element #${options.containerId} not found`); } this.container = container; this.container.classList.add('ldap-tree-viewer-container'); // Apply theme if (options.theme) { this.container.classList.add(`ldap-tree-viewer--${options.theme}`); } this.api = new LdapApiClient(options.apiBaseUrl, options.authToken); this.store = new Store(treeReducer, initialState); } async init(): Promise { try { // Load root organization const rootData = await this.api.getTopOrganization(); const rootNode = this.transformToTreeNode(rootData); this.store.dispatch(setRoot(rootNode)); // Create root component this.rootComponent = new TreeRoot( this.store, this.handleNodeClick.bind(this), this.handleNodeToggle.bind(this) ); // Subscribe to state changes for re-rendering this.unsubscribe = this.store.subscribe(() => { this.render(); }); // Initial render this.render(); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Failed to load LDAP tree'; this.store.dispatch(setError(errorMessage)); throw error; } } private render(): void { if (!this.rootComponent) return; const newTree = this.rootComponent.render(); // Replace content this.container.innerHTML = ''; this.container.appendChild(newTree); } private async handleNodeToggle(dn: string): Promise { const state = this.store.getState(); const node = state.nodes.get(dn); if (!node) return; const isExpanded = state.expandedNodes.has(dn); if (isExpanded) { // Collapse this.store.dispatch(toggleExpanded(dn)); } else { // Expand - load children if not loaded yet if (!node.hasLoadedChildren && node.hasChildren) { await this.loadChildren(dn); } this.store.dispatch(toggleExpanded(dn)); // Call onNodeExpand callback if (this.options.onNodeExpand) { this.options.onNodeExpand(node); } } } private async loadChildren(dn: string): Promise { this.store.dispatch(setLoading({ dn, loading: true })); try { const subnodes = await this.api.getOrganizationSubnodes(dn); const childDns: string[] = []; // Transform and add each node subnodes.forEach(nodeData => { const node = this.transformToTreeNode(nodeData, dn); this.store.dispatch(addNode(node)); childDns.push(node.dn); }); // Update parent node with children info const parentNode = this.store.getState().nodes.get(dn); if (parentNode) { this.store.dispatch( updateNode({ ...parentNode, hasLoadedChildren: true, childrenDns: childDns, hasChildren: childDns.length > 0, }) ); } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Failed to load children'; this.store.dispatch(setError(errorMessage)); console.error('Failed to load children for', dn, error); } finally { this.store.dispatch(setLoading({ dn, loading: false })); } } private handleNodeClick(dn: string): void { this.store.dispatch(selectNode(dn)); const node = this.store.getState().nodes.get(dn); if (node && this.options.onNodeClick) { this.options.onNodeClick(node); } } private transformToTreeNode( data: Record, parentDn: string | null = null ): TreeNode { // Determine display name let displayName = (data.ou || data.cn || data.uid || data.dn) as | string | string[] | undefined; if (Array.isArray(displayName)) { displayName = displayName[0]; } const nodeType = this.detectNodeType(data); return { dn: String(data.dn), displayName: displayName || String(data.dn), type: nodeType, parentDn, childrenDns: [], hasLoadedChildren: false, // Only organizations can have children, 'more' indicator cannot be expanded hasChildren: nodeType === 'organization', attributes: data, }; } private detectNodeType( data: Record ): 'organization' | 'user' | 'group' | 'more' { const objectClass = (data.objectClass || []) as string | string[]; const classes = Array.isArray(objectClass) ? objectClass : [objectClass]; if (classes.includes('moreIndicator')) { return 'more'; } if ( classes.includes('organizationalUnit') || classes.includes('organization') ) { return 'organization'; } if (classes.includes('posixGroup') || classes.includes('groupOfNames')) { return 'group'; } return 'user'; } // Public API methods async expandNode(dn: string): Promise { const state = this.store.getState(); if (!state.expandedNodes.has(dn)) { await this.handleNodeToggle(dn); } } async collapseNode(dn: string): Promise { const state = this.store.getState(); if (state.expandedNodes.has(dn)) { this.store.dispatch(toggleExpanded(dn)); } } async refresh(): Promise { // Clear state and reload this.store.dispatch(setError(null)); await this.init(); } selectNode(dn: string | null): void { this.store.dispatch(selectNode(dn)); } getState(): TreeState { return this.store.getState(); } destroy(): void { if (this.unsubscribe) { this.unsubscribe(); } this.container.innerHTML = ''; this.container.classList.remove('ldap-tree-viewer-container'); if (this.options.theme) { this.container.classList.remove( `ldap-tree-viewer--${this.options.theme}` ); } } } linagora-ldap-rest-16e557e/src/browser/ldap-tree-viewer/api/000077500000000000000000000000001522642357000237475ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-tree-viewer/api/LdapApiClient.ts000066400000000000000000000042721522642357000267750ustar00rootroot00000000000000/** * LDAP API Client for browser */ interface LdapOrganization { dn: string; ou?: string; [key: string]: unknown; } interface LdapSubnode { dn: string; type: string; [key: string]: unknown; } interface LdapListResponse { entries: unknown[]; total?: number; } export class LdapApiClient { constructor( private baseUrl: string, private authToken?: string ) {} private async fetch(endpoint: string): Promise { const headers: HeadersInit = { 'Content-Type': 'application/json', }; if (this.authToken) { headers['Authorization'] = `Bearer ${this.authToken}`; } const response = await fetch(`${this.baseUrl}${endpoint}`, { headers }); if (!response.ok) { const errorText = await response.text(); throw new Error(`API Error (${response.status}): ${errorText}`); } return response.json() as Promise; } async getTopOrganization(): Promise { return this.fetch('/api/v1/ldap/organizations/top'); } async getOrganization(dn: string): Promise { const encodedDn = encodeURIComponent(dn); return this.fetch( `/api/v1/ldap/organizations/${encodedDn}` ); } async getOrganizationSubnodes(dn: string): Promise { const encodedDn = encodeURIComponent(dn); return this.fetch( `/api/v1/ldap/organizations/${encodedDn}/subnodes` ); } async searchOrganizationSubnodes( dn: string, query: string ): Promise { const encodedDn = encodeURIComponent(dn); const encodedQuery = encodeURIComponent(query); return this.fetch( `/api/v1/ldap/organizations/${encodedDn}/subnodes/search?q=${encodedQuery}` ); } async getUsers(filter?: string): Promise { const query = filter ? `?filter=${encodeURIComponent(filter)}` : ''; return this.fetch(`/api/v1/ldap/users${query}`); } async getGroups(filter?: string): Promise { const query = filter ? `?filter=${encodeURIComponent(filter)}` : ''; return this.fetch(`/api/v1/ldap/groups${query}`); } } linagora-ldap-rest-16e557e/src/browser/ldap-tree-viewer/components/000077500000000000000000000000001522642357000253635ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-tree-viewer/components/TreeNode.ts000066400000000000000000000134371522642357000274500ustar00rootroot00000000000000/** * TreeNode component - renders individual tree nodes */ import type { TreeNode as TreeNodeData, TreeState } from '../types'; import type { Store } from '../store/Store'; export class TreeNodeComponent { constructor( private dn: string, private store: Store, private onToggle: (dn: string) => void, private onClick: (dn: string) => void ) {} render(): HTMLElement { const state = this.store.getState(); const node = state.nodes.get(this.dn); if (!node) { const li = document.createElement('li'); li.className = 'mdc-tree-node mdc-tree-node--error'; li.textContent = `Node not found: ${this.dn}`; return li; } const isExpanded = state.expandedNodes.has(this.dn); const isLoading = state.loadingNodes.has(this.dn); const isSelected = state.selectedNode === this.dn; const li = document.createElement('li'); li.className = this.getClassNames(node, isExpanded, isSelected, isLoading); li.dataset.dn = this.dn; // Node content const content = this.createContent(node, isExpanded, isLoading); li.appendChild(content); // Children if (isExpanded && node.childrenDns.length > 0) { const childrenUl = document.createElement('ul'); childrenUl.className = 'mdc-tree-node__children'; node.childrenDns.forEach(childDn => { const childComponent = new TreeNodeComponent( childDn, this.store, this.onToggle, this.onClick ); childrenUl.appendChild(childComponent.render()); }); li.appendChild(childrenUl); } return li; } private createContent( node: TreeNodeData, isExpanded: boolean, isLoading: boolean ): HTMLElement { const div = document.createElement('div'); div.className = 'mdc-tree-node__content'; // Toggle button or spacer if (node.hasChildren) { const toggle = document.createElement('button'); toggle.className = 'mdc-tree-node__toggle mdc-icon-button'; toggle.setAttribute('aria-label', isExpanded ? 'Collapse' : 'Expand'); toggle.innerHTML = `${ isExpanded ? 'expand_more' : 'chevron_right' }`; toggle.addEventListener('click', e => { e.stopPropagation(); this.onToggle(this.dn); }); div.appendChild(toggle); } else { const spacer = document.createElement('span'); spacer.className = 'mdc-tree-node__spacer'; div.appendChild(spacer); } // Icon const icon = document.createElement('span'); icon.className = 'mdc-tree-node__icon material-icons'; icon.textContent = this.getIcon(node.type); div.appendChild(icon); // Label const label = document.createElement('span'); label.className = 'mdc-tree-node__label'; label.textContent = node.displayName; label.title = node.dn; // Tooltip with full DN div.appendChild(label); // Loading spinner if (isLoading) { const spinner = this.createSpinner(); div.appendChild(spinner); } // Click handler on content div.addEventListener('click', () => { this.onClick(this.dn); }); return div; } private createSpinner(): HTMLElement { const spinner = document.createElement('div'); spinner.className = 'mdc-circular-progress mdc-circular-progress--small mdc-circular-progress--indeterminate'; spinner.setAttribute('role', 'progressbar'); spinner.setAttribute('aria-label', 'Loading'); spinner.innerHTML = `
`; return spinner; } private getClassNames( node: TreeNodeData, isExpanded: boolean, isSelected: boolean, isLoading: boolean ): string { const classes = ['mdc-tree-node', `mdc-tree-node--${node.type}`]; if (isExpanded) classes.push('mdc-tree-node--expanded'); if (isSelected) classes.push('mdc-tree-node--selected'); if (isLoading) classes.push('mdc-tree-node--loading'); return classes.join(' '); } private getIcon(type: string): string { const icons: Record = { organization: 'folder', user: 'person', group: 'group', more: 'more_horiz', }; return icons[type] || 'help'; } } linagora-ldap-rest-16e557e/src/browser/ldap-tree-viewer/components/TreeRoot.ts000066400000000000000000000101701522642357000274750ustar00rootroot00000000000000/** * TreeRoot component - renders the root container and tree */ import type { TreeState } from '../types'; import type { Store } from '../store/Store'; import { TreeNodeComponent } from './TreeNode'; export class TreeRoot { constructor( private store: Store, private onClick: (dn: string) => void, private onToggle: (dn: string) => void ) {} render(): HTMLElement { const state = this.store.getState(); const container = document.createElement('div'); container.className = 'ldap-tree-viewer'; // Error state if (state.error) { const error = this.createError(state.error); container.appendChild(error); return container; } // Loading state (no root yet) if (!state.rootDn) { const loading = this.createLoading(); container.appendChild(loading); return container; } // Tree const ul = document.createElement('ul'); ul.className = 'mdc-tree-root'; ul.setAttribute('role', 'tree'); const rootComponent = new TreeNodeComponent( state.rootDn, this.store, this.onToggle, this.onClick ); ul.appendChild(rootComponent.render()); container.appendChild(ul); return container; } private createError(errorMessage: string): HTMLElement { const error = document.createElement('div'); error.className = 'mdc-tree-error'; error.setAttribute('role', 'alert'); const icon = document.createElement('span'); icon.className = 'material-icons mdc-tree-error__icon'; icon.textContent = 'error_outline'; const message = document.createElement('span'); message.className = 'mdc-tree-error__message'; message.textContent = errorMessage; error.appendChild(icon); error.appendChild(message); return error; } private createLoading(): HTMLElement { const loading = document.createElement('div'); loading.className = 'mdc-tree-loading'; loading.setAttribute('role', 'status'); loading.setAttribute('aria-live', 'polite'); const spinner = document.createElement('div'); spinner.className = 'mdc-circular-progress mdc-circular-progress--indeterminate'; spinner.setAttribute('role', 'progressbar'); spinner.setAttribute('aria-label', 'Loading tree'); spinner.innerHTML = `
`; const text = document.createElement('span'); text.className = 'mdc-tree-loading__text'; text.textContent = 'Loading LDAP tree...'; loading.appendChild(spinner); loading.appendChild(text); return loading; } } linagora-ldap-rest-16e557e/src/browser/ldap-tree-viewer/index.ts000066400000000000000000000003701522642357000246550ustar00rootroot00000000000000/** * LDAP Tree Viewer - Browser library * @author Xavier Guimard */ import './styles.css'; export { LdapTreeViewer } from './LdapTreeViewer'; export type { ViewerOptions, TreeNode, TreeState, NodeType, FilterType, } from './types'; linagora-ldap-rest-16e557e/src/browser/ldap-tree-viewer/store/000077500000000000000000000000001522642357000243325ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-tree-viewer/store/Store.ts000066400000000000000000000017061522642357000260020ustar00rootroot00000000000000/** * Mini Store implementation (Redux-like pattern without dependencies) */ export type Listener = () => void; export type Reducer = (state: S, action: Action) => S; export interface Action { type: string; payload?: unknown; } export class Store { private state: S; private reducer: Reducer; private listeners: Set = new Set(); constructor(reducer: Reducer, initialState: S) { this.reducer = reducer; this.state = initialState; } getState(): S { return this.state; } dispatch(action: Action): void { const prevState = this.state; this.state = this.reducer(this.state, action); // Only notify if state actually changed if (prevState !== this.state) { this.listeners.forEach(listener => listener()); } } subscribe(listener: Listener): () => void { this.listeners.add(listener); // Return unsubscribe function return () => this.listeners.delete(listener); } } linagora-ldap-rest-16e557e/src/browser/ldap-tree-viewer/store/actions.ts000066400000000000000000000026571522642357000263540ustar00rootroot00000000000000/** * Action types and action creators */ import type { TreeNode } from '../types'; import type { Action } from './Store'; export const Actions = { SET_ROOT: 'SET_ROOT', ADD_NODE: 'ADD_NODE', UPDATE_NODE: 'UPDATE_NODE', TOGGLE_EXPANDED: 'TOGGLE_EXPANDED', SET_LOADING: 'SET_LOADING', SET_ERROR: 'SET_ERROR', SELECT_NODE: 'SELECT_NODE', SET_SEARCH: 'SET_SEARCH', SET_FILTER: 'SET_FILTER', } as const; export const setRoot = (node: TreeNode): Action => ({ type: Actions.SET_ROOT, payload: node, }); export const addNode = (node: TreeNode): Action => ({ type: Actions.ADD_NODE, payload: node, }); export const updateNode = (node: TreeNode): Action => ({ type: Actions.UPDATE_NODE, payload: node, }); export const toggleExpanded = (dn: string): Action => ({ type: Actions.TOGGLE_EXPANDED, payload: dn, }); export const setLoading = (payload: { dn: string; loading: boolean; }): Action => ({ type: Actions.SET_LOADING, payload, }); export const setError = (error: string | null): Action => ({ type: Actions.SET_ERROR, payload: error, }); export const selectNode = (dn: string | null): Action => ({ type: Actions.SELECT_NODE, payload: dn, }); export const setSearch = (query: string): Action => ({ type: Actions.SET_SEARCH, payload: query, }); export const setFilter = ( filter: 'all' | 'organizations' | 'users' | 'groups' ): Action => ({ type: Actions.SET_FILTER, payload: filter, }); linagora-ldap-rest-16e557e/src/browser/ldap-tree-viewer/store/reducers.ts000066400000000000000000000043131522642357000265170ustar00rootroot00000000000000/** * Reducers for tree state management */ import type { TreeState, TreeNode } from '../types'; import type { Action } from './Store'; import { Actions } from './actions'; export const initialState: TreeState = { nodes: new Map(), rootDn: null, expandedNodes: new Set(), loadingNodes: new Set(), selectedNode: null, error: null, searchQuery: '', filter: 'all', }; export function treeReducer(state: TreeState, action: Action): TreeState { switch (action.type) { case Actions.SET_ROOT: { const rootNode = action.payload as TreeNode; return { ...state, rootDn: rootNode.dn, nodes: new Map([[rootNode.dn, rootNode]]), error: null, }; } case Actions.ADD_NODE: { const newNode = action.payload as TreeNode; const nodes = new Map(state.nodes); nodes.set(newNode.dn, newNode); return { ...state, nodes }; } case Actions.UPDATE_NODE: { const updatedNode = action.payload as TreeNode; const nodes = new Map(state.nodes); nodes.set(updatedNode.dn, updatedNode); return { ...state, nodes }; } case Actions.TOGGLE_EXPANDED: { const dn = action.payload as string; const expanded = new Set(state.expandedNodes); if (expanded.has(dn)) { expanded.delete(dn); } else { expanded.add(dn); } return { ...state, expandedNodes: expanded }; } case Actions.SET_LOADING: { const { dn, loading } = action.payload as { dn: string; loading: boolean; }; const loadingNodes = new Set(state.loadingNodes); if (loading) { loadingNodes.add(dn); } else { loadingNodes.delete(dn); } return { ...state, loadingNodes }; } case Actions.SET_ERROR: { return { ...state, error: action.payload as string | null }; } case Actions.SELECT_NODE: { return { ...state, selectedNode: action.payload as string | null }; } case Actions.SET_SEARCH: { return { ...state, searchQuery: action.payload as string }; } case Actions.SET_FILTER: { return { ...state, filter: action.payload as TreeState['filter'] }; } default: return state; } } linagora-ldap-rest-16e557e/src/browser/ldap-tree-viewer/styles.css000066400000000000000000000163071522642357000252420ustar00rootroot00000000000000/** * LDAP Tree Viewer - Material Design Styles */ /* CSS Variables - Material Design tokens */ :root { --mdc-theme-primary: #6200ee; --mdc-theme-primary-variant: #3700b3; --mdc-theme-on-primary: #ffffff; --mdc-theme-surface: #ffffff; --mdc-theme-on-surface: rgba(0, 0, 0, 0.87); --mdc-theme-background: #fafafa; --mdc-theme-error: #b00020; /* Tree specific */ --tree-node-height: 40px; --tree-indent: 24px; --tree-icon-size: 24px; --tree-toggle-size: 40px; --tree-border-radius: 4px; --tree-transition: 200ms cubic-bezier(0.4, 0, 0.2, 1); } /* Dark theme */ .ldap-tree-viewer--dark { --mdc-theme-surface: #121212; --mdc-theme-on-surface: rgba(255, 255, 255, 0.87); --mdc-theme-background: #1e1e1e; } /* Container */ .ldap-tree-viewer-container { font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; line-height: 1.5; color: var(--mdc-theme-on-surface); } .ldap-tree-viewer { background: var(--mdc-theme-surface); border-radius: var(--tree-border-radius); padding: 8px; min-height: 200px; } /* Tree root */ .mdc-tree-root { list-style: none; margin: 0; padding: 0; } /* Tree node */ .mdc-tree-node { list-style: none; margin: 0; padding: 0; user-select: none; } .mdc-tree-node__content { display: flex; align-items: center; min-height: var(--tree-node-height); padding: 0 8px; cursor: pointer; border-radius: var(--tree-border-radius); transition: background-color var(--tree-transition); position: relative; } .mdc-tree-node__content:hover { background-color: rgba(0, 0, 0, 0.04); } .ldap-tree-viewer--dark .mdc-tree-node__content:hover { background-color: rgba(255, 255, 255, 0.08); } .mdc-tree-node--selected > .mdc-tree-node__content { background-color: rgba(98, 0, 238, 0.12); } .ldap-tree-viewer--dark .mdc-tree-node--selected > .mdc-tree-node__content { background-color: rgba(187, 134, 252, 0.24); } /* Toggle button */ .mdc-tree-node__toggle { width: var(--tree-toggle-size); height: var(--tree-toggle-size); padding: 8px; margin: 0; border: none; background: transparent; cursor: pointer; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: background-color var(--tree-transition); flex-shrink: 0; } .mdc-tree-node__toggle:hover { background-color: rgba(0, 0, 0, 0.04); } .ldap-tree-viewer--dark .mdc-tree-node__toggle:hover { background-color: rgba(255, 255, 255, 0.08); } .mdc-tree-node__toggle .material-icons { font-size: var(--tree-icon-size); color: var(--mdc-theme-on-surface); transition: transform var(--tree-transition); } .mdc-tree-node--expanded > .mdc-tree-node__content .mdc-tree-node__toggle .material-icons { transform: rotate(0deg); } /* Spacer for nodes without children */ .mdc-tree-node__spacer { width: var(--tree-toggle-size); height: var(--tree-toggle-size); flex-shrink: 0; } /* Icon */ .mdc-tree-node__icon { width: var(--tree-icon-size); height: var(--tree-icon-size); font-size: var(--tree-icon-size); margin-right: 12px; flex-shrink: 0; color: rgba(0, 0, 0, 0.54); } .ldap-tree-viewer--dark .mdc-tree-node__icon { color: rgba(255, 255, 255, 0.7); } .mdc-tree-node--organization > .mdc-tree-node__content .mdc-tree-node__icon { color: #ffa726; } .mdc-tree-node--user > .mdc-tree-node__content .mdc-tree-node__icon { color: #42a5f5; } .mdc-tree-node--group > .mdc-tree-node__content .mdc-tree-node__icon { color: #66bb6a; } /* Label */ .mdc-tree-node__label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Children */ .mdc-tree-node__children { list-style: none; margin: 0; padding-left: var(--tree-indent); overflow: hidden; } /* Loading spinner */ .mdc-circular-progress { display: inline-block; width: 48px; height: 48px; margin-left: auto; } .mdc-circular-progress--small { width: 20px; height: 20px; margin-left: 8px; } .mdc-circular-progress--indeterminate .mdc-circular-progress__determinate-container { display: none; } .mdc-circular-progress__indeterminate-container { animation: mdc-circular-progress-container-rotate 1568ms linear infinite; } @keyframes mdc-circular-progress-container-rotate { to { transform: rotate(360deg); } } .mdc-circular-progress__spinner-layer { animation: mdc-circular-progress-spinner-layer-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite; } @keyframes mdc-circular-progress-spinner-layer-rotate { 12.5% { transform: rotate(135deg); } 25% { transform: rotate(270deg); } 37.5% { transform: rotate(405deg); } 50% { transform: rotate(540deg); } 62.5% { transform: rotate(675deg); } 75% { transform: rotate(810deg); } 87.5% { transform: rotate(945deg); } 100% { transform: rotate(1080deg); } } .mdc-circular-progress__circle-clipper { display: inline-flex; position: relative; width: 50%; height: 100%; overflow: hidden; } .mdc-circular-progress__circle-clipper .mdc-circular-progress__indeterminate-circle-graphic { width: 200%; } .mdc-circular-progress__circle-left .mdc-circular-progress__indeterminate-circle-graphic { animation: mdc-circular-progress-left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite; transform-origin: right center; } @keyframes mdc-circular-progress-left-spin { from { transform: rotate(130deg); } 50% { transform: rotate(-5deg); } to { transform: rotate(130deg); } } .mdc-circular-progress__circle-right .mdc-circular-progress__indeterminate-circle-graphic { animation: mdc-circular-progress-right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite; left: -100%; transform-origin: left center; } @keyframes mdc-circular-progress-right-spin { from { transform: rotate(-130deg); } 50% { transform: rotate(5deg); } to { transform: rotate(-130deg); } } .mdc-circular-progress__gap-patch { position: absolute; top: 0; left: 47.5%; width: 5%; height: 100%; overflow: hidden; } .mdc-circular-progress__gap-patch .mdc-circular-progress__indeterminate-circle-graphic { width: 1000%; left: -450%; } .mdc-circular-progress__indeterminate-circle-graphic circle { fill: transparent; stroke: var(--mdc-theme-primary); stroke-linecap: square; } /* Loading state */ .mdc-tree-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; gap: 16px; } .mdc-tree-loading__text { color: var(--mdc-theme-on-surface); opacity: 0.6; } /* Error state */ .mdc-tree-error { display: flex; align-items: center; padding: 16px; background-color: rgba(176, 0, 32, 0.08); border-radius: var(--tree-border-radius); color: var(--mdc-theme-error); gap: 12px; } .mdc-tree-error__icon { font-size: 24px; flex-shrink: 0; } .mdc-tree-error__message { flex: 1; } /* Material icons fallback */ .material-icons { font-family: 'Material Icons'; font-weight: normal; font-style: normal; font-size: 24px; line-height: 1; letter-spacing: normal; text-transform: none; display: inline-block; white-space: nowrap; word-wrap: normal; direction: ltr; -webkit-font-feature-settings: 'liga'; -webkit-font-smoothing: antialiased; } linagora-ldap-rest-16e557e/src/browser/ldap-tree-viewer/types.ts000066400000000000000000000017321522642357000247150ustar00rootroot00000000000000/** * TypeScript interfaces and types for LDAP Tree Viewer */ export interface TreeState { nodes: Map; rootDn: string | null; expandedNodes: Set; loadingNodes: Set; selectedNode: string | null; error: string | null; searchQuery: string; filter: 'all' | 'organizations' | 'users' | 'groups'; } export interface TreeNode { dn: string; displayName: string; type: 'organization' | 'user' | 'group' | 'more'; parentDn: string | null; childrenDns: string[]; hasLoadedChildren: boolean; hasChildren: boolean; attributes?: Record; } export interface ViewerOptions { containerId: string; apiBaseUrl: string; authToken?: string; rootDn?: string; theme?: 'light' | 'dark'; onNodeClick?: (node: TreeNode) => void; onNodeExpand?: (node: TreeNode) => void; } export type NodeType = 'organization' | 'user' | 'group' | 'more'; export type FilterType = 'all' | 'organizations' | 'users' | 'groups'; linagora-ldap-rest-16e557e/src/browser/ldap-unit-editor/000077500000000000000000000000001522642357000232035ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-unit-editor/LdapUnitEditor.ts000066400000000000000000000107251522642357000264470ustar00rootroot00000000000000/** * LDAP Unit Editor - Main component for editing organizational units */ import { UnitTree } from './components/UnitTree.js'; import { UnitPropertyEditor } from './components/UnitPropertyEditor.js'; import { UnitApiClient } from './api/UnitApiClient.js'; import type { UnitEditorOptions, Config } from './types'; export class LdapUnitEditor { private container: HTMLElement | null = null; private options: UnitEditorOptions; private api: UnitApiClient; private unitTree: UnitTree | null = null; private unitEditor: UnitPropertyEditor | null = null; private currentUnitDn: string | null = null; private config: Config | null = null; constructor(options: UnitEditorOptions) { this.options = options; this.api = new UnitApiClient(options.apiBaseUrl || ''); } async init(): Promise { const container = document.getElementById(this.options.containerId); if (!container) { throw new Error( `Container element with id '${this.options.containerId}' not found` ); } this.container = container; // Load config first this.config = await this.api.getConfig(); this.render(); await this.initializeComponents(); } private render(): void { if (!this.container) return; this.container.innerHTML = `
business

Select an organizational unit to edit

`; } private async initializeComponents(): Promise { const treeContainer = document.getElementById('unit-tree-container'); if (!treeContainer) return; this.unitTree = new UnitTree( treeContainer, this.options.apiBaseUrl || '', unitDn => this.onUnitSelected(unitDn) ); await this.unitTree.init(); } private async onUnitSelected(unitDn: string): Promise { this.currentUnitDn = unitDn; const editorContainer = document.getElementById('unit-editor-container'); if (!editorContainer) return; try { this.unitEditor = new UnitPropertyEditor( editorContainer, this.api, unitDn ); await this.unitEditor.init(); this.unitEditor.onSave(() => { if (this.options.onUnitSaved) { this.options.onUnitSaved(unitDn); } }); } catch (error) { console.error('Failed to load unit editor:', error); editorContainer.innerHTML = `

Error loading unit

${(error as Error).message}

`; if (this.options.onError) { this.options.onError(error as Error); } } } getCurrentUnitDn(): string | null { return this.currentUnitDn; } async refresh(): Promise { if (this.unitTree) { await this.unitTree.init(); } if (this.currentUnitDn && this.unitEditor) { const editorContainer = document.getElementById('unit-editor-container'); if (editorContainer) { this.unitEditor = new UnitPropertyEditor( editorContainer, this.api, this.currentUnitDn ); await this.unitEditor.init(); } } } // Public API for compatibility with tests getApi(): UnitApiClient { return this.api; } getConfig(): Config | null { return this.config; } getCurrentOrgDn(): string | null { // For units, the current org is the current unit return this.currentUnitDn; } getCurrentUserDn(): string | null { // For compatibility - in unit context, "user" means "unit" return this.currentUnitDn; } async createUser(data: Record): Promise { // For compatibility - actually creates an organizational unit return this.api.createEntry(data.dn as string, data); } async deleteUser(dn: string): Promise { // For compatibility - actually deletes an organizational unit return this.api.deleteEntry(dn); } } linagora-ldap-rest-16e557e/src/browser/ldap-unit-editor/api/000077500000000000000000000000001522642357000237545ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-unit-editor/api/UnitApiClient.ts000066400000000000000000000071611522642357000270410ustar00rootroot00000000000000/** * API Client for LDAP Unit operations */ import type { Config, LdapUnit } from '../types'; export class UnitApiClient { private baseUrl: string; constructor(baseUrl: string) { this.baseUrl = baseUrl; } async getConfig(): Promise { const response = await fetch(`${this.baseUrl}/api/v1/config`); if (!response.ok) { throw new Error(`Failed to load config: ${response.statusText}`); } return response.json(); } async getOrganizations(): Promise<{ dn: string }> { const response = await fetch( `${this.baseUrl}/api/v1/ldap/organizations/top` ); if (!response.ok) { throw new Error(`Failed to load organizations: ${response.statusText}`); } return response.json(); } async getUnit(dn: string): Promise { const response = await fetch( `${this.baseUrl}/api/v1/ldap/organizations/${encodeURIComponent(dn)}` ); if (!response.ok) { throw new Error(`Failed to load unit: ${response.statusText}`); } return response.json(); } async updateUnit(dn: string, data: Partial): Promise { const response = await fetch( `${this.baseUrl}/api/v1/ldap/organizations/${encodeURIComponent(dn)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), } ); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to update unit: ${error}`); } } async createEntry(dn: string, data: Record): Promise { // Use the organizations API for creating units // Extract ou and parentDn from DN // DN format: ou=value,parent,dn,parts const match = dn.match(/^ou=([^,]+),(.+)$/); if (!match) { throw new Error(`Invalid DN format: ${dn}`); } const [, ou, parentDn] = match; const response = await fetch(`${this.baseUrl}/api/v1/ldap/organizations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ou, parentDn, ...data }), }); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to create entry: ${error}`); } } async deleteEntry(dn: string): Promise { // Use the organizations API for deleting units const response = await fetch( `${this.baseUrl}/api/v1/ldap/organizations/${encodeURIComponent(dn)}`, { method: 'DELETE', } ); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to delete entry: ${error}`); } } async getPointerOptions( branch: string ): Promise> { const response = await fetch( `${this.baseUrl}/api/v1/ldap/search?base=${encodeURIComponent(branch)}&scope=one` ); if (!response.ok) { throw new Error(`Failed to load pointer options: ${response.statusText}`); } const entries = await response.json(); return entries.map((entry: any) => ({ dn: entry.dn, label: entry.cn?.[0] || entry.ou?.[0] || entry.dn, })); } async moveUnit( dn: string, targetOrgDn: string ): Promise<{ success: boolean; newDn?: string }> { const response = await fetch( `${this.baseUrl}/api/v1/ldap/organizations/${encodeURIComponent(dn)}/move`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetOrgDn }), } ); if (!response.ok) { const error = await response.text(); throw new Error(error || 'Failed to move unit'); } return response.json(); } } linagora-ldap-rest-16e557e/src/browser/ldap-unit-editor/components/000077500000000000000000000000001522642357000253705ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-unit-editor/components/MoveUnitModal.ts000066400000000000000000000246251522642357000304740ustar00rootroot00000000000000/** * LDAP Unit Editor - Move Unit Modal Component * Modal dialog for selecting target organization when moving a unit */ import type { UnitApiClient } from '../api/UnitApiClient'; export class MoveUnitModal { private modal: HTMLElement | null = null; private api: UnitApiClient; private currentOrgDn: string; private selectedOrgDn: string | null = null; private onMove: (targetOrgDn: string) => void; private expandedNodes: Set = new Set(); private rootDn: string | null = null; constructor( api: UnitApiClient, currentOrgDn: string, onMove: (targetOrgDn: string) => void ) { this.api = api; this.currentOrgDn = currentOrgDn; this.onMove = onMove; } /** * Escape HTML special characters to prevent XSS attacks */ private static escapeHtml(text: unknown): string { if (text == null) return ''; const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', }; return String(text).replace(/[&<>"'/]/g, m => map[m]); } async show(): Promise { // Create modal container this.modal = document.createElement('div'); this.modal.className = 'modal-overlay active'; this.modal.innerHTML = ` `; document.body.appendChild(this.modal); this.attachHandlers(); await this.loadOrganizationTree(); } private attachHandlers(): void { if (!this.modal) return; // Close button const closeBtn = this.modal.querySelector('#modal-close-btn'); closeBtn?.addEventListener('click', () => this.close()); // Cancel button const cancelBtn = this.modal.querySelector('#modal-cancel-btn'); cancelBtn?.addEventListener('click', () => this.close()); // Move button const moveBtn = this.modal.querySelector('#modal-move-btn'); moveBtn?.addEventListener('click', () => this.handleMove()); // Close on overlay click this.modal.addEventListener('click', e => { if (e.target === this.modal) { this.close(); } }); // Close on Escape key const handleEscape = (e: KeyboardEvent): void => { if (e.key === 'Escape') { this.close(); document.removeEventListener('keydown', handleEscape); } }; document.addEventListener('keydown', handleEscape); } private async loadOrganizationTree(): Promise { const container = this.modal?.querySelector('#organization-tree-container'); if (!container) return; try { // Get top organization const topOrg = await this.api.getOrganizations(); this.rootDn = topOrg.dn; // Expand root by default this.expandedNodes.add(this.rootDn); await this.renderTree(); } catch (error) { container.innerHTML = `
error
Failed to load organizations: ${MoveUnitModal.escapeHtml((error as Error).message)}
`; } } private async renderTree(): Promise { const container = this.modal?.querySelector('#organization-tree-container'); if (!container || !this.rootDn) return; try { const html = await this.renderNode(this.rootDn, 0); container.innerHTML = html; this.attachTreeHandlers(); } catch (error) { container.innerHTML = `
error
Error rendering tree: ${MoveUnitModal.escapeHtml((error as Error).message)}
`; } } private async renderNode(dn: string, level: number): Promise { const isExpanded = this.expandedNodes.has(dn); const isCurrentOrg = dn === this.currentOrgDn; const isSelected = dn === this.selectedOrgDn; const indent = level * 20; // Get node info const node = await this.api.getUnit(dn); // Extract display name const ou = Array.isArray(node.ou) ? node.ou[0] : node.ou; const path = node.twakeDepartmentPath ? Array.isArray(node.twakeDepartmentPath) ? node.twakeDepartmentPath[0] : node.twakeDepartmentPath : undefined; const displayName = path || ou || dn; let html = `
${isExpanded ? 'expand_more' : 'chevron_right'} ${isCurrentOrg ? 'business' : 'folder'} ${MoveUnitModal.escapeHtml(displayName)} ${isCurrentOrg ? '(current)' : ''} ${isSelected ? 'check_circle' : ''}
`; // If expanded, load and render subnodes if (isExpanded) { try { // Use the organizations subnodes API to get only organizational units const response = await fetch( `${this.api['baseUrl']}/api/v1/ldap/organizations/${encodeURIComponent(dn)}/subnodes` ); if (!response.ok) { throw new Error('Failed to load subnodes'); } const subnodes = await response.json(); // Filter for organizational units only const orgs = subnodes.filter((n: any) => { const classes = Array.isArray(n.objectClass) ? n.objectClass : n.objectClass ? [n.objectClass] : []; return ( classes.includes('organizationalUnit') || classes.includes('organization') ); }); for (const subnode of orgs) { html += await this.renderNode(subnode.dn, level + 1); } } catch (error) { console.error('Failed to load subnodes for', dn, error); // No subnodes or error loading them } } return html; } private attachTreeHandlers(): void { // Handle tree toggle (expand/collapse) const toggles = this.modal?.querySelectorAll('.tree-toggle'); toggles?.forEach(toggle => { toggle.addEventListener('click', async e => { e.stopPropagation(); const dn = toggle.getAttribute('data-dn'); if (!dn) return; if (this.expandedNodes.has(dn)) { this.expandedNodes.delete(dn); } else { this.expandedNodes.add(dn); } await this.renderTree(); }); }); // Handle node selection const nodes = this.modal?.querySelectorAll('.org-node'); nodes?.forEach(node => { const orgDn = node.getAttribute('data-org-dn'); if (!orgDn || orgDn === this.currentOrgDn) return; node.addEventListener('click', () => { this.selectOrganization(orgDn); }); }); } private async selectOrganization(orgDn: string): Promise { this.selectedOrgDn = orgDn; // Re-render tree to update selection state await this.renderTree(); // Enable move button const moveBtn = this.modal?.querySelector( '#modal-move-btn' ) as HTMLButtonElement; if (moveBtn) { moveBtn.disabled = false; } } private async handleMove(): Promise { if (!this.selectedOrgDn) return; const moveBtn = this.modal?.querySelector( '#modal-move-btn' ) as HTMLButtonElement; const alertContainer = this.modal?.querySelector('#move-modal-alert'); if (moveBtn) { moveBtn.disabled = true; moveBtn.innerHTML = ` Moving... `; } try { this.onMove(this.selectedOrgDn); this.close(); } catch (error) { if (alertContainer) { alertContainer.innerHTML = `
error
Move failed: ${MoveUnitModal.escapeHtml((error as Error).message)}
`; } if (moveBtn) { moveBtn.disabled = false; moveBtn.innerHTML = ` drive_file_move Move Unit `; } } } close(): void { if (this.modal) { this.modal.remove(); this.modal = null; } } } linagora-ldap-rest-16e557e/src/browser/ldap-unit-editor/components/UnitPropertyEditor.ts000066400000000000000000000322631522642357000316010ustar00rootroot00000000000000/** * Unit Property Editor Component - Edits properties of a selected organizational unit */ import type { UnitApiClient } from '../api/UnitApiClient'; import type { LdapUnit, SchemaDefinition } from '../types'; import { MoveUnitModal } from './MoveUnitModal'; export class UnitPropertyEditor { private container: HTMLElement; private api: UnitApiClient; private unitDn: string; private unit: LdapUnit | null = null; private schema: SchemaDefinition | null = null; private saveCallback: (() => void) | null = null; private hasSubnodes: boolean = false; constructor(container: HTMLElement, api: UnitApiClient, unitDn: string) { this.container = container; this.api = api; this.unitDn = unitDn; } async init(): Promise { await this.loadUnit(); await this.loadSchema(); await this.checkSubnodes(); this.render(); this.attachEventListeners(); } private async loadUnit(): Promise { this.unit = await this.api.getUnit(this.unitDn); } private async checkSubnodes(): Promise { try { const response = await fetch( `${this.api['baseUrl']}/api/v1/ldap/organizations/${encodeURIComponent(this.unitDn)}/subnodes` ); if (response.ok) { const subnodes = await response.json(); // Check if there are any organizational subnodes this.hasSubnodes = subnodes.some((node: any) => { const classes = Array.isArray(node.objectClass) ? node.objectClass : node.objectClass ? [node.objectClass] : []; return ( classes.includes('organizationalUnit') || classes.includes('organization') ); }); } } catch (error) { console.error('Failed to check subnodes:', error); // On error, assume it might have subnodes to be safe this.hasSubnodes = false; } } private async loadSchema(): Promise { console.warn('[UnitPropertyEditor] Loading schema...'); const config = await this.api.getConfig(); // First check in features.ldapOrganizations (from ldapOrganizations plugin) const orgsConfig = (config.features as any)?.ldapOrganizations; console.warn('[UnitPropertyEditor] Organizations config:', orgsConfig); if (orgsConfig?.schemaUrl) { console.warn( '[UnitPropertyEditor] Loading schema from URL:', orgsConfig.schemaUrl ); const response = await fetch(orgsConfig.schemaUrl); this.schema = await response.json(); console.warn('[UnitPropertyEditor] Schema loaded from URL:', this.schema); } else if (orgsConfig?.schema) { console.warn('[UnitPropertyEditor] Using schema from config'); this.schema = orgsConfig.schema; } else { // Fallback: check in flatResources console.warn('[UnitPropertyEditor] Falling back to flatResources'); const unitsResource = config.features?.ldapFlatGeneric?.flatResources?.find( r => r.pluralName === 'organizations' || r.name === 'organizations' ); if (unitsResource?.schemaUrl) { const response = await fetch(unitsResource.schemaUrl); this.schema = await response.json(); } else if (unitsResource?.schema) { this.schema = unitsResource.schema; } else { console.warn('[UnitPropertyEditor] No schema found, using default'); // Create a minimal default schema this.schema = { entity: { objectClass: ['organizationalUnit'], }, attributes: { ou: { type: 'string', required: true }, description: { type: 'string' }, }, }; } } } onSave(callback: () => void): void { this.saveCallback = callback; } private render(): void { if (!this.unit) return; this.container.innerHTML = `

business Edit Organizational Unit

${this.renderFields()}
`; } private renderFields(): string { if (!this.unit || !this.schema) { return '
Loading...
'; } let html = ''; // Group fields by category const basicFields: [string, any][] = []; const otherFields: [string, any][] = []; for (const [fieldName, attribute] of Object.entries( this.schema.attributes )) { if (attribute.fixed || fieldName === 'objectClass') continue; if (fieldName === 'ou' || fieldName === 'description') { basicFields.push([fieldName, attribute]); } else { otherFields.push([fieldName, attribute]); } } // Basic Information if (basicFields.length > 0) { html += '
'; html += '
Basic Information
'; for (const [fieldName, attribute] of basicFields) { html += this.renderField(fieldName, attribute); } html += '
'; } // Additional Properties if (otherFields.length > 0) { html += '
'; html += '
Additional Properties
'; for (const [fieldName, attribute] of otherFields) { html += this.renderField(fieldName, attribute); } html += '
'; } return html; } private renderField(fieldName: string, attribute: any): string { const value = this.unit?.[fieldName]; const label = this.getFieldLabel(fieldName); const required = attribute.required ? 'required' : ''; if (attribute.type === 'array') { const arrayValue = Array.isArray(value) ? value.join('\n') : ''; return `
Enter one value per line
`; } if (attribute.type === 'number' || attribute.type === 'integer') { return `
`; } // String field return `
`; } private getFieldLabel(fieldName: string): string { const labelMap: Record = { ou: 'Organization Unit', description: 'Description', }; return ( labelMap[fieldName] || fieldName .replace(/([A-Z])/g, ' $1') .replace(/_/g, ' ') .replace(/^./, str => str.toUpperCase()) ); } private attachEventListeners(): void { const form = document.getElementById('unit-edit-form') as HTMLFormElement; if (form) { form.addEventListener('submit', e => this.handleSubmit(e)); } const deleteBtn = document.getElementById('delete-unit-btn'); if (deleteBtn) { deleteBtn.addEventListener('click', () => this.handleDelete()); } const moveBtn = document.getElementById('move-unit-btn'); if (moveBtn) { moveBtn.addEventListener('click', () => this.handleMove()); } } private async handleSubmit(e: Event): Promise { e.preventDefault(); const form = e.target as HTMLFormElement; const formData = new FormData(form); const updates: Record = {}; for (const [key, value] of (formData as any).entries()) { if (this.schema?.attributes[key]?.type === 'array') { updates[key] = (value as string) .split('\n') .map(v => v.trim()) .filter(v => v); } else if ( this.schema?.attributes[key]?.type === 'number' || this.schema?.attributes[key]?.type === 'integer' ) { updates[key] = Number(value); } else { updates[key] = value; } } try { await this.api.updateUnit(this.unitDn, updates); alert('Organizational unit updated successfully!'); if (this.saveCallback) { this.saveCallback(); } } catch (error) { console.error('Failed to update unit:', error); alert(`Failed to update unit: ${(error as Error).message}`); } } private async handleMove(): Promise { if (!this.unit) return; // Extract parent DN from current unit DN const parentDn = this.unitDn.split(',').slice(1).join(','); const modal = new MoveUnitModal(this.api, parentDn, async targetOrgDn => { try { const result = await this.api.moveUnit(this.unitDn, targetOrgDn); alert('Unit moved successfully!'); // Reload the unit with its new DN if (result.newDn) { this.unitDn = result.newDn; await this.loadUnit(); this.render(); this.attachEventListeners(); } if (this.saveCallback) { this.saveCallback(); } } catch (error) { console.error('Failed to move unit:', error); alert(`Failed to move unit: ${(error as Error).message}`); } }); await modal.show(); } private async handleDelete(): Promise { // Don't allow delete if there are subnodes if (this.hasSubnodes) { alert( 'Cannot delete this organizational unit because it contains sub-organizations. Please delete all sub-organizations first.' ); return; } if ( !confirm( `Are you sure you want to delete this organizational unit?\n\n${this.unitDn}\n\nThis action cannot be undone.` ) ) { return; } try { await this.api.deleteEntry(this.unitDn); alert('Organizational unit deleted successfully!'); this.container.innerHTML = `
business

Unit deleted. Select another unit to edit.

`; } catch (error) { console.error('Failed to delete unit:', error); alert(`Failed to delete unit: ${(error as Error).message}`); } } private escapeHtml(text: string | null | undefined): string { if (text == null) return ''; const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', }; return String(text).replace(/[&<>"'/]/g, m => map[m]); } } linagora-ldap-rest-16e557e/src/browser/ldap-unit-editor/components/UnitTree.ts000066400000000000000000000162161522642357000275050ustar00rootroot00000000000000/** * Unit Tree Component - Shows organizations tree (same as UserTree) */ export class UnitTree { private container: HTMLElement; private apiBaseUrl: string; private onOrgSelect: (orgDn: string) => void; private expandedNodes = new Set(); private selectedDn: string | null = null; private rootDn: string | null = null; constructor( container: HTMLElement, apiBaseUrl: string, onOrgSelect: (orgDn: string) => void ) { this.container = container; this.apiBaseUrl = apiBaseUrl; this.onOrgSelect = onOrgSelect; } async init(): Promise { this.container.innerHTML = `

account_tree Organizations

`; try { const response = await fetch( `${this.apiBaseUrl}/api/v1/ldap/organizations/top` ); if (!response.ok) throw new Error('Failed to load organizations'); const topOrg = await response.json(); this.rootDn = topOrg.dn; await this.renderTree(); // Attach create button listener const createBtn = this.container.querySelector('#create-unit-btn'); if (createBtn) { createBtn.addEventListener('click', () => this.handleCreateUnit()); } } catch (error) { console.error('Failed to init tree:', error); const treeEl = this.container.querySelector('#tree-content'); if (treeEl) { treeEl.innerHTML = '

Failed to load organizations

'; } } } private async handleCreateUnit(): Promise { const parentDn = this.selectedDn || this.rootDn; if (!parentDn) { alert('Please select a parent organization first'); return; } // eslint-disable-next-line no-undef const ouName = prompt( 'Enter the name for the new organizational unit (ou):' ); if (!ouName || !ouName.trim()) return; try { const response = await fetch( `${this.apiBaseUrl}/api/v1/ldap/organizations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ou: ouName.trim(), parentDn, }), } ); if (!response.ok) { const error = await response.text(); throw new Error(error || 'Failed to create organizational unit'); } alert(`Organizational unit "${ouName}" created successfully!`); // Expand the parent node and refresh tree this.expandedNodes.add(parentDn); await this.renderTree(); } catch (error) { console.error('Failed to create unit:', error); alert(`Failed to create unit: ${(error as Error).message}`); } } private async renderTree(): Promise { const treeEl = this.container.querySelector('#tree-content'); if (!treeEl || !this.rootDn) return; try { const html = await this.renderNode(this.rootDn, 0); treeEl.innerHTML = html; this.attachEventListeners(); } catch (error) { console.error('Failed to render tree:', error); treeEl.innerHTML = '

Error rendering tree

'; } } private async renderNode(dn: string, level: number): Promise { const isExpanded = this.expandedNodes.has(dn); const isSelected = this.selectedDn === dn; const indent = level * 20; // Get node info const response = await fetch( `${this.apiBaseUrl}/api/v1/ldap/organizations/${encodeURIComponent(dn)}` ); if (!response.ok) throw new Error('Failed to load node'); const node = await response.json(); // Extract display name let displayName = dn; if (node.ou) { displayName = Array.isArray(node.ou) ? node.ou[0] : node.ou; } else if (node.cn) { displayName = Array.isArray(node.cn) ? node.cn[0] : node.cn; } let html = `
${isExpanded ? 'expand_more' : 'chevron_right'} business ${this.escapeHtml(displayName)}
`; if (isExpanded) { try { const subnodesResponse = await fetch( `${this.apiBaseUrl}/api/v1/ldap/organizations/${encodeURIComponent(dn)}/subnodes` ); if (!subnodesResponse.ok) throw new Error('Failed to load subnodes'); const subnodes = await subnodesResponse.json(); const orgs = subnodes.filter((n: any) => { const classes = Array.isArray(n.objectClass) ? n.objectClass : n.objectClass ? [n.objectClass] : []; return ( classes.includes('organizationalUnit') || classes.includes('organization') ); }); for (const subnode of orgs) { html += await this.renderNode(subnode.dn, level + 1); } } catch (error) { console.error('Failed to load subnodes for', dn, error); } } return html; } private attachEventListeners(): void { // Toggle expand/collapse this.container.querySelectorAll('.tree-node-toggle').forEach(el => { el.addEventListener('click', async e => { e.stopPropagation(); const dn = el.getAttribute('data-dn'); if (!dn) return; if (this.expandedNodes.has(dn)) { this.expandedNodes.delete(dn); } else { this.expandedNodes.add(dn); } await this.renderTree(); }); }); // Select organization this.container.querySelectorAll('.tree-node-label').forEach(el => { el.addEventListener('click', e => { e.stopPropagation(); const dn = el.getAttribute('data-dn'); if (!dn) return; this.selectedDn = dn; this.onOrgSelect(dn); this.renderTree(); }); }); } private escapeHtml(text: string | null | undefined): string { if (text == null) return ''; const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', }; return String(text).replace(/[&<>"'/]/g, m => map[m]); } } linagora-ldap-rest-16e557e/src/browser/ldap-unit-editor/index.ts000066400000000000000000000003021522642357000246550ustar00rootroot00000000000000/** * LDAP Unit Editor - Entry point * @author Xavier Guimard */ export { LdapUnitEditor } from './LdapUnitEditor.js'; export type { UnitEditorOptions, Config, LdapUnit } from './types.js'; linagora-ldap-rest-16e557e/src/browser/ldap-unit-editor/types.ts000066400000000000000000000022241522642357000247170ustar00rootroot00000000000000/** * TypeScript types for LDAP Unit Editor */ export interface UnitEditorOptions { containerId: string; apiBaseUrl?: string; onUnitSaved?: (unitDn: string) => void; onError?: (error: Error) => void; } export interface Config { ldapBase: string; features?: { flatResources?: ResourceConfig[]; ldapFlatGeneric?: { flatResources?: ResourceConfig[]; }; ldapOrganizations?: { schemaUrl?: string; schema?: SchemaDefinition; }; }; [key: string]: unknown; } export interface ResourceConfig { name: string; pluralName?: string; schemaUrl?: string; schema?: SchemaDefinition; base?: string; } export interface SchemaDefinition { entity: { objectClass?: string[]; base?: string; }; attributes: Record; } export interface SchemaAttribute { type: 'string' | 'number' | 'integer' | 'array' | 'pointer'; required?: boolean; fixed?: boolean; default?: unknown; branch?: string[]; items?: { type: string; branch?: string[]; }; group?: string; } export interface LdapUnit { dn: string; ou: string; description?: string; [key: string]: unknown; } linagora-ldap-rest-16e557e/src/browser/ldap-user-editor/000077500000000000000000000000001522642357000232025ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-user-editor/LdapUserEditor.ts000066400000000000000000000131421522642357000264410ustar00rootroot00000000000000/** * LDAP User Editor - Main class * @author Xavier Guimard */ import type { EditorOptions, Config, LdapUser } from './types'; import { UserApiClient } from './api/UserApiClient'; import { UserTree } from './components/UserTree'; import { UserEditor } from './components/UserEditor'; import { UserList } from './components/UserList'; export class LdapUserEditor { private options: EditorOptions; private api: UserApiClient; private container: HTMLElement | null = null; private userTree: UserTree | null = null; private userEditor: UserEditor | null = null; private userList: UserList | null = null; private currentUserDn: string | null = null; private currentOrgDn: string | null = null; private config: Config | null = null; constructor(options: EditorOptions) { this.options = { apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '', ...options, }; this.api = new UserApiClient(this.options.apiBaseUrl); } async init(): Promise { this.container = document.getElementById(this.options.containerId); if (!this.container) { throw new Error(`Container #${this.options.containerId} not found`); } // Load config first this.config = await this.api.getConfig(); this.render(); await this.initComponents(); } private render(): void { if (!this.container) return; this.container.innerHTML = `
person_search

Select a user from the list to edit

`; } private async initComponents(): Promise { const treeContainer = document.getElementById('user-tree-container'); if (!treeContainer) return; this.userTree = new UserTree( treeContainer, this.options.apiBaseUrl || window.location.origin, orgDn => this.onOrganizationSelected(orgDn) ); try { await this.userTree.init(); } catch (error) { this.handleError(error as Error); } } private async onOrganizationSelected(dn: string): Promise { const editorContainer = document.getElementById('user-editor-container'); if (!editorContainer) return; this.currentOrgDn = dn; this.currentUserDn = null; try { // Show user list for this organization this.userList = new UserList(editorContainer, this.api, dn, userDn => this.onUserSelected(userDn) ); await this.userList.init(); } catch (error) { this.handleError(error as Error); editorContainer.innerHTML = `
error

Failed to load users for this organization

`; } } private async onUserSelected(dn: string): Promise { this.currentUserDn = dn; const editorContainer = document.getElementById('user-editor-container'); if (!editorContainer) return; this.userEditor = new UserEditor( editorContainer, this.api, dn, () => { if (this.options.onUserSaved) { this.options.onUserSaved(dn); } // Refresh the user list if we have one if (this.currentOrgDn && this.userList) { this.userList.refresh(); } }, () => { // On user deleted this.deleteUser(dn); } ); try { await this.userEditor.init(); } catch (error) { this.handleError(error as Error); } } private handleError(error: Error): void { console.error('LDAP User Editor error:', error); if (this.options.onError) { this.options.onError(error); } } async refresh(): Promise { await this.userTree?.refresh(); if (this.currentUserDn) { await this.userEditor?.refresh(); } } destroy(): void { if (this.container) { this.container.innerHTML = ''; } this.userTree = null; this.userEditor = null; this.currentUserDn = null; } async createUser(userData: Partial): Promise { try { const result = await this.api.createUser(userData); // Refresh the user list if we have one if (this.userList) { await this.userList.refresh(); } return result; } catch (error) { this.handleError(error as Error); throw error; } } async deleteUser(dn: string): Promise { try { await this.api.deleteUser(dn); // Clear current selection if we deleted the selected user if (this.currentUserDn === dn) { this.currentUserDn = null; const editorContainer = document.getElementById( 'user-editor-container' ); if (editorContainer) { editorContainer.innerHTML = `
person_search

Select a user from the list to edit

`; } } // Refresh the user list if (this.userList) { await this.userList.refresh(); } } catch (error) { this.handleError(error as Error); throw error; } } getConfig(): Config | null { return this.config; } getApi(): UserApiClient { return this.api; } getCurrentOrgDn(): string | null { return this.currentOrgDn; } getCurrentUserDn(): string | null { return this.currentUserDn; } } linagora-ldap-rest-16e557e/src/browser/ldap-user-editor/api/000077500000000000000000000000001522642357000237535ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-user-editor/api/UserApiClient.ts000066400000000000000000000174411522642357000270410ustar00rootroot00000000000000/** * LDAP User Editor - API Client */ import type { Config, LdapUser, PointerOption, Schema } from '../types'; import { CacheManager } from '../cache/CacheManager'; export class UserApiClient { private baseUrl: string; private cache: CacheManager; constructor( baseUrl: string = '', cacheOptions?: { ttl?: number; maxEntries?: number } ) { this.baseUrl = baseUrl || (typeof window !== 'undefined' ? window.location.origin : ''); this.cache = new CacheManager(cacheOptions); // Clean expired entries every 5 minutes (only in browser context) if (typeof window !== 'undefined') { window.setInterval(() => this.cache.cleanExpired(), 5 * 60 * 1000); } } private getApiBase(): string { return `${this.baseUrl}/api/v1`; } private getFirstValue(value: unknown): string { if (!value) return ''; if (Array.isArray(value)) return value[0] || ''; return String(value); } /** * Fetch with cache support for GET requests */ private async cachedFetch(url: string, options?: RequestInit): Promise { const method = options?.method || 'GET'; // Only cache GET requests if (method === 'GET') { const cached = this.cache.get(url); if (cached !== null) { return cached; } } const res = await fetch(url, options); if (!res.ok) { throw new Error(`HTTP ${res.status}: ${res.statusText}`); } const data = (await res.json()) as T; // Cache GET responses if (method === 'GET') { this.cache.set(url, data); } return data; } async getConfig(): Promise { return this.cachedFetch(`${this.getApiBase()}/config`); } async getSchema(schemaUrl: string): Promise { // Handle relative URLs by prepending baseUrl const url = schemaUrl.startsWith('http') ? schemaUrl : `${this.baseUrl}${schemaUrl}`; return this.cachedFetch(url); } async getUsers(search = ''): Promise { const url = search ? `${this.getApiBase()}/ldap/users?match=${encodeURIComponent(search)}&attribute=uid` : `${this.getApiBase()}/ldap/users`; const data = await this.cachedFetch>( url ); // Convert from object format {uid: entry} to array format return Array.isArray(data) ? data : Object.values(data); } async getUser(dn: string): Promise { const url = `${this.getApiBase()}/ldap/users/${encodeURIComponent(dn)}`; return this.cachedFetch(url); } async updateUser(dn: string, data: Partial): Promise { const res = await fetch( `${this.getApiBase()}/ldap/users/${encodeURIComponent(dn)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), } ); if (!res.ok) { const error = await res.text(); throw new Error(error || 'Failed to update user'); } const result = await res.json(); // Invalidate cache for this user and user lists this.cache.invalidate( `${this.getApiBase()}/ldap/users/${encodeURIComponent(dn)}` ); this.cache.invalidatePattern(`${this.getApiBase()}/ldap/users*`); return result; } async createUser(data: Partial): Promise { const res = await fetch(`${this.getApiBase()}/ldap/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!res.ok) { const error = await res.text(); throw new Error(error || 'Failed to create user'); } const result = await res.json(); // Invalidate cache for user lists this.cache.invalidatePattern(`${this.getApiBase()}/ldap/users*`); return result; } async deleteUser(dn: string): Promise { const res = await fetch( `${this.getApiBase()}/ldap/users/${encodeURIComponent(dn)}`, { method: 'DELETE', } ); if (!res.ok) { const error = await res.text(); throw new Error(error || 'Failed to delete user'); } // Invalidate cache for this user and user lists this.cache.invalidate( `${this.getApiBase()}/ldap/users/${encodeURIComponent(dn)}` ); this.cache.invalidatePattern(`${this.getApiBase()}/ldap/users*`); } async moveUser( dn: string, targetOrgDn: string ): Promise<{ success: boolean; newDn?: string }> { const res = await fetch( `${this.getApiBase()}/ldap/users/${encodeURIComponent(dn)}/move`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetOrgDn }), } ); if (!res.ok) { const error = await res.text(); throw new Error(error || 'Failed to move user'); } const result = await res.json(); // Invalidate cache for this user and user lists this.cache.invalidate( `${this.getApiBase()}/ldap/users/${encodeURIComponent(dn)}` ); this.cache.invalidatePattern(`${this.getApiBase()}/ldap/users*`); return result; } async getPointerOptions(branch: string): Promise { try { // Load config to find the right endpoint by matching base const config = await this.getConfig(); // Find resource that matches this branch by comparing base const resource = config.features?.ldapFlatGeneric?.flatResources?.find( r => { // Match if branch equals base or branch starts with base return branch === r.base || branch.startsWith(r.base); } ); if (resource && resource.endpoints?.list) { // Use the endpoint from config const url = `${this.baseUrl}${resource.endpoints.list}`; const data = await this.cachedFetch< LdapUser[] | Record >(url); // Convert from object format {key: entry} to array const items = Array.isArray(data) ? data : Object.values(data); // Load schema to get displayName field let displayNameField = 'cn'; // Default fallback const identifierField = resource.mainAttribute; if (resource.schemaUrl) { try { const schema = await this.getSchema(resource.schemaUrl); // Find field with displayName role const displayField = Object.entries(schema.attributes).find( ([, attr]) => attr.role === 'displayName' ); if (displayField) { displayNameField = displayField[0]; } // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { // Use defaults if schema fails } } return items.map((item: LdapUser) => { const displayName = this.getFirstValue(item[displayNameField]); const identifier = this.getFirstValue(item[identifierField]); return { dn: item.dn, label: displayName || identifier || item.dn, }; }); } // No matching resource found in config throw new Error(`No flatResource found in config for branch: ${branch}`); } catch (error) { console.error( 'Failed to load pointer options for branch:', branch, error ); return []; } } /** * Cache management methods */ /** * Clear all cache */ clearCache(): void { this.cache.clear(); } /** * Invalidate cache for a specific URL pattern */ invalidateCache(pattern: string): void { this.cache.invalidatePattern(pattern); } /** * Get cache statistics */ getCacheStats(): { size: number; maxSize: number; ttl: number; keys: string[]; } { return this.cache.getStats(); } /** * Get cache instance (for advanced usage) */ getCache(): CacheManager { return this.cache; } } linagora-ldap-rest-16e557e/src/browser/ldap-user-editor/cache/000077500000000000000000000000001522642357000242455ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-user-editor/cache/CacheManager.ts000066400000000000000000000066361522642357000271260ustar00rootroot00000000000000/** * Simple in-memory cache manager with LRU eviction * Prevents excessive API calls when navigating the organization tree */ interface CacheEntry { data: T; timestamp: number; accessCount: number; } export interface CacheOptions { /** * Time to live in milliseconds (default: 5 minutes) */ ttl?: number; /** * Maximum number of entries (default: 200) */ maxEntries?: number; } export class CacheManager { private cache: Map> = new Map(); private ttl: number; private maxEntries: number; constructor(options: CacheOptions = {}) { this.ttl = options.ttl ?? 5 * 60 * 1000; // 5 minutes default this.maxEntries = options.maxEntries ?? 200; // 200 entries max } /** * Get an item from cache * Returns null if not found or expired */ get(key: string): T | null { const entry = this.cache.get(key) as CacheEntry | undefined; if (!entry) return null; // Check if expired if (Date.now() - entry.timestamp >= this.ttl) { this.cache.delete(key); return null; } // Update access count for LRU entry.accessCount++; entry.timestamp = Date.now(); return entry.data; } /** * Set an item in cache * Evicts least recently used items if cache is full */ set(key: string, data: T): void { // If cache is full, evict LRU entry if (this.cache.size >= this.maxEntries && !this.cache.has(key)) { this.evictLRU(); } this.cache.set(key, { data, timestamp: Date.now(), accessCount: 0, }); } /** * Check if key exists and is not expired */ has(key: string): boolean { return this.get(key) !== null; } /** * Invalidate a specific key */ invalidate(key: string): void { this.cache.delete(key); } /** * Invalidate all keys matching a pattern * Pattern can contain wildcards (*) */ invalidatePattern(pattern: string): void { const regex = new RegExp( '^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$' ); for (const key of this.cache.keys()) { if (regex.test(key)) { this.cache.delete(key); } } } /** * Clear all cache */ clear(): void { this.cache.clear(); } /** * Get cache statistics */ getStats(): { size: number; maxSize: number; ttl: number; keys: string[]; } { return { size: this.cache.size, maxSize: this.maxEntries, ttl: this.ttl, keys: Array.from(this.cache.keys()), }; } /** * Evict least recently used entry */ private evictLRU(): void { let lruKey: string | null = null; let lruTime = Infinity; let lruCount = Infinity; for (const [key, entry] of this.cache.entries()) { // Prioritize by access count, then by timestamp if ( entry.accessCount < lruCount || (entry.accessCount === lruCount && entry.timestamp < lruTime) ) { lruKey = key; lruTime = entry.timestamp; lruCount = entry.accessCount; } } if (lruKey) { this.cache.delete(lruKey); } } /** * Clean expired entries * Should be called periodically */ cleanExpired(): number { const now = Date.now(); let cleaned = 0; for (const [key, entry] of this.cache.entries()) { if (now - entry.timestamp >= this.ttl) { this.cache.delete(key); cleaned++; } } return cleaned; } } linagora-ldap-rest-16e557e/src/browser/ldap-user-editor/components/000077500000000000000000000000001522642357000253675ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/ldap-user-editor/components/MoveUserModal.ts000066400000000000000000000262701522642357000304700ustar00rootroot00000000000000/** * LDAP User Editor - Move User Modal Component * Modal dialog for selecting target organization when moving a user */ import type { UserApiClient } from '../api/UserApiClient'; export class MoveUserModal { private modal: HTMLElement | null = null; private api: UserApiClient; private currentOrgDn: string; private selectedOrgDn: string | null = null; private onMove: (targetOrgDn: string) => void; private expandedNodes: Set = new Set(); private rootDn: string | null = null; constructor( api: UserApiClient, currentOrgDn: string, onMove: (targetOrgDn: string) => void ) { this.api = api; this.currentOrgDn = currentOrgDn; this.onMove = onMove; } /** * Escape HTML special characters to prevent XSS attacks */ private static escapeHtml(text: unknown): string { if (text == null) return ''; const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', }; return String(text).replace(/[&<>"'/]/g, m => map[m]); } async show(): Promise { // Create modal container this.modal = document.createElement('div'); this.modal.className = 'modal-overlay active'; this.modal.innerHTML = ` `; document.body.appendChild(this.modal); this.attachHandlers(); await this.loadOrganizationTree(); } private attachHandlers(): void { if (!this.modal) return; // Close button const closeBtn = this.modal.querySelector('#modal-close-btn'); closeBtn?.addEventListener('click', () => this.close()); // Cancel button const cancelBtn = this.modal.querySelector('#modal-cancel-btn'); cancelBtn?.addEventListener('click', () => this.close()); // Move button const moveBtn = this.modal.querySelector('#modal-move-btn'); moveBtn?.addEventListener('click', () => this.handleMove()); // Close on overlay click this.modal.addEventListener('click', e => { if (e.target === this.modal) { this.close(); } }); // Close on Escape key const handleEscape = (e: KeyboardEvent): void => { if (e.key === 'Escape') { this.close(); document.removeEventListener('keydown', handleEscape); } }; document.addEventListener('keydown', handleEscape); } private async loadOrganizationTree(): Promise { console.log('loadOrganizationTree called'); const container = this.modal?.querySelector('#organization-tree-container'); if (!container) { console.log('Container not found'); return; } try { console.log('Fetching top organization...'); // Get top organization const topOrg = await this.api['cachedFetch']<{ dn: string }>( `${this.api['baseUrl'] || window.location.origin}/api/v1/ldap/organizations/top` ); console.log('Top org:', topOrg); this.rootDn = topOrg.dn; // Expand root by default this.expandedNodes.add(this.rootDn); console.log('Calling renderTree...'); await this.renderTree(); console.log('renderTree completed'); } catch (error) { console.error('Error in loadOrganizationTree:', error); container.innerHTML = `
error
Failed to load organizations: ${MoveUserModal.escapeHtml((error as Error).message)}
`; } } private async renderTree(): Promise { console.log('renderTree called'); const container = this.modal?.querySelector('#organization-tree-container'); if (!container || !this.rootDn) { console.log('renderTree: no container or rootDn', { container, rootDn: this.rootDn, }); return; } try { console.log('renderTree: calling renderNode for', this.rootDn); const html = await this.renderNode(this.rootDn, 0); console.log('renderTree: got html, length:', html.length); container.innerHTML = html; console.log('renderTree: html set, attaching handlers'); this.attachTreeHandlers(); console.log('renderTree: handlers attached'); } catch (error) { console.error('Error in renderTree:', error); container.innerHTML = `
error
Error rendering tree: ${MoveUserModal.escapeHtml((error as Error).message)}
`; } } private async renderNode(dn: string, level: number): Promise { console.log('renderNode called for', dn, 'level', level); const isExpanded = this.expandedNodes.has(dn); const isCurrentOrg = dn === this.currentOrgDn; const isSelected = dn === this.selectedOrgDn; const indent = level * 20; // Get node info const baseUrl = this.api['baseUrl'] || window.location.origin; console.log( 'Fetching node details from:', `${baseUrl}/api/v1/ldap/organizations/${encodeURIComponent(dn)}` ); const node = await this.api['cachedFetch']<{ dn: string; ou?: string | string[]; twakeDepartmentPath?: string | string[]; }>(`${baseUrl}/api/v1/ldap/organizations/${encodeURIComponent(dn)}`); console.log('Got node:', node); // Extract display name const ou = Array.isArray(node.ou) ? node.ou[0] : node.ou; const path = node.twakeDepartmentPath ? Array.isArray(node.twakeDepartmentPath) ? node.twakeDepartmentPath[0] : node.twakeDepartmentPath : undefined; const displayName = path || ou || dn; let html = `
${isExpanded ? 'expand_more' : 'chevron_right'} ${isCurrentOrg ? 'business' : 'folder'} ${MoveUserModal.escapeHtml(displayName)} ${isCurrentOrg ? '(current)' : ''} ${isSelected ? 'check_circle' : ''}
`; // If expanded, load and render subnodes if (isExpanded) { try { const subnodes = await this.api['cachedFetch']< Array<{ dn: string; objectClass?: string[] }> >( `${baseUrl}/api/v1/ldap/organizations/${encodeURIComponent(dn)}/subnodes?objectClass=organizationalUnit` ); for (const subnode of subnodes) { html += await this.renderNode(subnode.dn, level + 1); } } catch { // No subnodes or error loading them } } return html; } private attachTreeHandlers(): void { // Handle tree toggle (expand/collapse) const toggles = this.modal?.querySelectorAll('.tree-toggle'); toggles?.forEach(toggle => { toggle.addEventListener('click', async e => { e.stopPropagation(); const dn = toggle.getAttribute('data-dn'); if (!dn) return; if (this.expandedNodes.has(dn)) { this.expandedNodes.delete(dn); } else { this.expandedNodes.add(dn); } await this.renderTree(); }); }); // Handle node selection const nodes = this.modal?.querySelectorAll('.org-node'); nodes?.forEach(node => { const orgDn = node.getAttribute('data-org-dn'); if (!orgDn || orgDn === this.currentOrgDn) return; node.addEventListener('click', () => { this.selectOrganization(orgDn); }); }); } private async selectOrganization(orgDn: string): Promise { this.selectedOrgDn = orgDn; // Re-render tree to update selection state await this.renderTree(); // Enable move button const moveBtn = this.modal?.querySelector( '#modal-move-btn' ) as HTMLButtonElement; if (moveBtn) { moveBtn.disabled = false; } } private async handleMove(): Promise { if (!this.selectedOrgDn) return; const moveBtn = this.modal?.querySelector( '#modal-move-btn' ) as HTMLButtonElement; const alertContainer = this.modal?.querySelector('#move-modal-alert'); if (moveBtn) { moveBtn.disabled = true; moveBtn.innerHTML = ` Moving... `; } try { this.onMove(this.selectedOrgDn); this.close(); } catch (error) { if (alertContainer) { alertContainer.innerHTML = `
error
Move failed: ${MoveUserModal.escapeHtml((error as Error).message)}
`; } if (moveBtn) { moveBtn.disabled = false; moveBtn.innerHTML = ` drive_file_move Move User `; } } } close(): void { if (this.modal) { this.modal.remove(); this.modal = null; } } } linagora-ldap-rest-16e557e/src/browser/ldap-user-editor/components/PointerField.ts000066400000000000000000000124451522642357000303310ustar00rootroot00000000000000/** * LDAP User Editor - Pointer Field Component */ import type { PointerOption, SchemaAttribute } from '../types'; import type { UserApiClient } from '../api/UserApiClient'; export class PointerField { private api: UserApiClient; private attribute: SchemaAttribute; private fieldName: string; private value: string | string[]; private onChange: (value: string | string[]) => void; private options: PointerOption[] = []; constructor( api: UserApiClient, fieldName: string, attribute: SchemaAttribute, value: string | string[], onChange: (value: string | string[]) => void ) { this.api = api; this.fieldName = fieldName; this.attribute = attribute; this.value = value; this.onChange = onChange; } async init(): Promise { await this.loadOptions(); } private async loadOptions(): Promise { try { const branch = this.attribute.type === 'array' ? this.attribute.items?.branch?.[0] : this.attribute.branch?.[0]; if (!branch) { this.options = []; return; } this.options = await this.api.getPointerOptions(branch); } catch (error) { console.error('Failed to load pointer options:', error); this.options = []; } } render(): string { const isArray = this.attribute.type === 'array'; const isRequired = this.attribute.required || false; if (isArray) { return this.renderArray(isRequired); } return this.renderSingle(isRequired); } private renderSingle(required: boolean): string { const currentValue = Array.isArray(this.value) ? this.value[0] || '' : this.value || ''; return `
`; } private renderArray(required: boolean): string { const values = Array.isArray(this.value) ? this.value : []; return `
${values .map( (val, idx) => `
` ) .join('')}
`; } attachHandlers(container: HTMLElement): void { if (this.attribute.type === 'array') { this.attachArrayHandlers(container); } else { this.attachSingleHandlers(container); } } private attachSingleHandlers(container: HTMLElement): void { const select = container.querySelector( `select[data-field="${this.fieldName}"]` ) as HTMLSelectElement; select?.addEventListener('change', () => { this.onChange(select.value); }); } private attachArrayHandlers(container: HTMLElement): void { const arrayContainer = container.querySelector( `.array-field[data-field="${this.fieldName}"]` ); if (!arrayContainer) return; // Handle item change arrayContainer .querySelectorAll('.pointer-array-item') .forEach((select, idx) => { select.addEventListener('change', () => { const values = Array.isArray(this.value) ? [...this.value] : []; values[idx] = (select as HTMLSelectElement).value; this.onChange(values); }); }); // Handle remove arrayContainer .querySelectorAll('.remove-array-item') .forEach((btn, idx) => { btn.addEventListener('click', () => { const values = Array.isArray(this.value) ? [...this.value] : []; values.splice(idx, 1); this.onChange(values); }); }); // Handle add const addBtn = arrayContainer.querySelector('.add-array-item'); addBtn?.addEventListener('click', () => { const values = Array.isArray(this.value) ? [...this.value] : []; values.push(''); this.onChange(values); }); } } linagora-ldap-rest-16e557e/src/browser/ldap-user-editor/components/UserEditor.ts000066400000000000000000000521671522642357000300370ustar00rootroot00000000000000/** * LDAP User Editor - User Editor Component */ import type { LdapUser, Schema, SchemaAttribute } from '../types'; import type { UserApiClient } from '../api/UserApiClient'; import { PointerField } from './PointerField'; import { MoveUserModal } from './MoveUserModal'; export class UserEditor { private container: HTMLElement; private api: UserApiClient; private userDn: string; private user: LdapUser | null = null; private schema: Schema | null = null; private formData: Partial = {}; private pointerFields: Map = new Map(); private onSaved?: () => void; private onDeleted?: () => void; private ldapBase: string = ''; /** * Escape HTML special characters to prevent XSS attacks * Reference: https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html */ private static escapeHtml(text: unknown): string { if (text == null) return ''; const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', }; return String(text).replace(/[&<>"'/]/g, m => map[m]); } constructor( container: HTMLElement, api: UserApiClient, userDn: string, onSaved?: () => void, onDeleted?: () => void ) { this.container = container; this.api = api; this.userDn = userDn; this.onSaved = onSaved; this.onDeleted = onDeleted; } async init(): Promise { try { this.showLoading(); await this.loadUserAndSchema(); this.render(); await this.initPointerFields(); this.attachHandlers(); } catch (error) { this.showError((error as Error).message); } } private showLoading(): void { this.container.innerHTML = `

Loading user data...

`; } private showError(message: string): void { this.container.innerHTML = `
error
${UserEditor.escapeHtml(message)}
`; } private async loadUserAndSchema(): Promise { const config = await this.api.getConfig(); this.ldapBase = config.ldapBase; // Find users resource in flatResources array const usersResource = config.features?.ldapFlatGeneric?.flatResources?.find( r => r.pluralName === 'users' || r.name === 'users' ); const schemaUrl = usersResource?.schemaUrl; if (!schemaUrl) { throw new Error('Users schema not configured'); } [this.schema, this.user] = await Promise.all([ this.api.getSchema(schemaUrl), this.api.getUser(this.userDn), ]); // Replace __LDAP_BASE__ placeholder in schema branches if (this.schema) { this.replacePlaceholders(this.schema); } this.formData = { ...this.user }; } private replacePlaceholders(schema: Schema): void { for (const attr of Object.values(schema.attributes)) { if (attr.branch) { attr.branch = attr.branch.map(b => b.replace(/__LDAP_BASE__/g, this.ldapBase) ); } if (attr.items?.branch) { attr.items.branch = attr.items.branch.map(b => b.replace(/__LDAP_BASE__/g, this.ldapBase) ); } } } private getFirstValue(value: unknown): string { if (!value) return ''; if (Array.isArray(value)) return value[0] || ''; return String(value); } private getFieldByRole(role: string): string { if (!this.schema || !this.user) return ''; const field = Object.entries(this.schema.attributes).find( ([, attr]) => attr.role === role ); if (!field) return ''; const [fieldName] = field; return this.getFirstValue(this.user[fieldName]); } private render(): void { if (!this.user || !this.schema) return; const displayName = this.getFieldByRole('displayName'); const identifier = this.getFirstValue( this.user[this.schema.entity.mainAttribute] ); const userName = displayName || identifier || 'User'; this.container.innerHTML = `
person

${UserEditor.escapeHtml(userName)}

${UserEditor.escapeHtml(this.userDn)}
${this.renderFormSections()}
`; } private renderFormSections(): string { if (!this.schema) return ''; // Group fields by the 'group' attribute from schema const groupedFields: Record = {}; const ungroupedFields: string[] = []; for (const [fieldName, attr] of Object.entries(this.schema.attributes)) { // Skip objectClass (it's fixed) if (fieldName === 'objectClass') continue; if (attr.group) { if (!groupedFields[attr.group]) { groupedFields[attr.group] = []; } groupedFields[attr.group].push(fieldName); } else { ungroupedFields.push(fieldName); } } // Render grouped sections let html = Object.entries(groupedFields) .map( ([title, fields]) => `
${UserEditor.escapeHtml(title)}
${fields .map(field => this.renderField(field)) .filter(f => f) .join('')}
` ) .join(''); // Render ungrouped fields in a default section if any if (ungroupedFields.length > 0) { html += `
Other Fields
${ungroupedFields .map(field => this.renderField(field)) .filter(f => f) .join('')}
`; } return html; } private renderField(fieldName: string): string { const attr = this.schema?.attributes[fieldName]; if (!attr) return ''; // Skip fixed fields - they cannot be edited if (attr.fixed) { return ''; } // Hide organizationLink and organizationPath fields - use Move button instead if (attr.role === 'organizationLink' || attr.role === 'organizationPath') { return ''; } // Show identifier fields as disabled if (attr.role === 'identifier') { return this.renderIdentifierField(fieldName); } // Pointer fields if (attr.type === 'pointer' || attr.items?.type === 'pointer') { return `
`; } // Array fields if (attr.type === 'array') { return this.renderArrayField(fieldName, attr); } // Number fields if (attr.type === 'number' || attr.type === 'integer') { return this.renderNumberField(fieldName, attr); } // String fields return this.renderStringField(fieldName, attr); } private renderIdentifierField(fieldName: string): string { const value = this.formData[fieldName]; const displayValue = Array.isArray(value) ? value[0] : value || ''; return `
`; } private renderStringField(fieldName: string, attr: SchemaAttribute): string { const value = this.formData[fieldName] || ''; const inputType = attr.test?.includes('@') ? 'email' : fieldName === 'userPassword' ? 'password' : 'text'; return `
`; } private renderNumberField(fieldName: string, attr: SchemaAttribute): string { const value = this.formData[fieldName] || ''; return `
`; } private renderArrayField(fieldName: string, attr: SchemaAttribute): string { const values = Array.isArray(this.formData[fieldName]) ? (this.formData[fieldName] as string[]) : []; const inputType = attr.items?.type === 'string' && attr.items?.test?.includes('@') ? 'email' : 'text'; return `
${values .map( (val, idx) => `
` ) .join('')}
`; } private async initPointerFields(): Promise { if (!this.schema) return; const pointerFieldNames = Object.entries(this.schema.attributes) .filter( ([, attr]) => (attr.type === 'pointer' || attr.items?.type === 'pointer') && !attr.fixed ) .map(([name]) => name); for (const fieldName of pointerFieldNames) { const attr = this.schema.attributes[fieldName]; const value = this.formData[fieldName] as string | string[]; const pointerField = new PointerField( this.api, fieldName, attr, value, newValue => { this.formData[fieldName] = newValue; this.rerenderPointerField(fieldName); } ); await pointerField.init(); this.pointerFields.set(fieldName, pointerField); // Render into placeholder const placeholder = this.container.querySelector(`#pointer-${fieldName}`); if (placeholder) { placeholder.innerHTML = pointerField.render(); pointerField.attachHandlers(placeholder as HTMLElement); } } } private rerenderPointerField(fieldName: string): void { const pointerField = this.pointerFields.get(fieldName); if (!pointerField) return; const placeholder = this.container.querySelector(`#pointer-${fieldName}`); if (placeholder) { placeholder.innerHTML = pointerField.render(); pointerField.attachHandlers(placeholder as HTMLElement); } } private attachHandlers(): void { const form = this.container.querySelector('#user-edit-form'); form?.addEventListener('submit', e => this.handleSubmit(e)); // Move button const moveBtn = this.container.querySelector('#move-user-btn'); moveBtn?.addEventListener('click', () => this.handleMove()); // Delete button const deleteBtn = this.container.querySelector('#delete-user-btn'); deleteBtn?.addEventListener('click', () => this.handleDelete()); // String/number inputs this.container .querySelectorAll('input[name]') .forEach(input => { input.addEventListener('input', () => { const fieldName = input.name; const attr = this.schema?.attributes[fieldName]; if (attr?.type === 'number' || attr?.type === 'integer') { this.formData[fieldName] = input.value ? parseInt(input.value, 10) : ''; } else { this.formData[fieldName] = input.value; } }); }); // Array fields this.attachArrayFieldHandlers(); } private attachArrayFieldHandlers(): void { // Handle array item changes this.container .querySelectorAll('.array-field') .forEach((arrayContainer: Element) => { const fieldName = arrayContainer.getAttribute('data-field'); if (!fieldName) return; arrayContainer .querySelectorAll('.array-string-item') .forEach((input, idx) => { input.addEventListener('input', () => { const values = Array.isArray(this.formData[fieldName]) ? [...(this.formData[fieldName] as string[])] : []; values[idx] = (input as HTMLInputElement).value; this.formData[fieldName] = values; }); }); arrayContainer .querySelectorAll('.remove-array-item') .forEach((btn, idx) => { btn.addEventListener('click', () => { const values = Array.isArray(this.formData[fieldName]) ? [...(this.formData[fieldName] as string[])] : []; values.splice(idx, 1); this.formData[fieldName] = values; this.rerenderArrayField(fieldName); }); }); }); // Handle add buttons this.container.querySelectorAll('.add-array-item').forEach(btn => { btn.addEventListener('click', () => { const fieldName = btn.getAttribute('data-field'); if (!fieldName) return; const values = Array.isArray(this.formData[fieldName]) ? [...(this.formData[fieldName] as string[])] : []; values.push(''); this.formData[fieldName] = values; this.rerenderArrayField(fieldName); }); }); } private rerenderArrayField(fieldName: string): void { const attr = this.schema?.attributes[fieldName]; if (!attr) return; const arrayContainer = this.container.querySelector( `.array-field[data-field="${fieldName}"]` )?.parentElement; if (arrayContainer) { const newHtml = attr.type === 'pointer' || attr.items?.type === 'pointer' ? this.pointerFields.get(fieldName)?.render() || '' : this.renderArrayField(fieldName, attr); arrayContainer.outerHTML = newHtml; // Reattach handlers if (attr.type === 'pointer' || attr.items?.type === 'pointer') { const placeholder = this.container.querySelector( `#pointer-${fieldName}` ); if (placeholder) { const pointerField = this.pointerFields.get(fieldName); pointerField?.attachHandlers(placeholder as HTMLElement); } } else { this.attachArrayFieldHandlers(); } } } private async handleSubmit(e: Event): Promise { e.preventDefault(); const alertContainer = this.container.querySelector('#alert-container'); const submitBtn = this.container.querySelector( 'button[type="submit"]' ) as HTMLButtonElement; if (alertContainer) alertContainer.innerHTML = ''; if (submitBtn) submitBtn.disabled = true; try { await this.api.updateUser(this.userDn, this.formData); if (alertContainer) { alertContainer.innerHTML = `
check_circle
User updated successfully!
`; } if (this.onSaved) this.onSaved(); } catch (error) { if (alertContainer) { alertContainer.innerHTML = `
error
${UserEditor.escapeHtml((error as Error).message)}
`; } } finally { if (submitBtn) submitBtn.disabled = false; } } private async handleMove(): Promise { if (!this.user || !this.schema) return; // Find the organizationLink field to get current organization const orgLinkField = Object.entries(this.schema.attributes).find( ([, attr]) => attr.role === 'organizationLink' ); if (!orgLinkField) { alert('This user type does not support moving between organizations'); return; } const [orgLinkFieldName] = orgLinkField; const currentOrgDn = Array.isArray(this.user[orgLinkFieldName]) ? String(this.user[orgLinkFieldName][0]) : String(this.user[orgLinkFieldName] || ''); if (!currentOrgDn) { alert('User does not have an organization link'); return; } const modal = new MoveUserModal( this.api, currentOrgDn, async (targetOrgDn: string) => { await this.performMove(targetOrgDn); } ); await modal.show(); } private async performMove(targetOrgDn: string): Promise { const alertContainer = this.container.querySelector('#alert-container'); const moveBtn = this.container.querySelector( '#move-user-btn' ) as HTMLButtonElement; if (alertContainer) alertContainer.innerHTML = ''; if (moveBtn) { moveBtn.disabled = true; moveBtn.innerHTML = ` Moving... `; } try { const result = await this.api.moveUser(this.userDn, targetOrgDn); if (alertContainer) { alertContainer.innerHTML = `
check_circle
User moved successfully!
`; } // Update the userDn if it changed if (result.newDn) { this.userDn = result.newDn; } // Reload the user data to show updated organization await this.init(); if (this.onSaved) this.onSaved(); } catch (error) { if (alertContainer) { alertContainer.innerHTML = `
error
${UserEditor.escapeHtml((error as Error).message)}
`; } // Re-enable button on error if (moveBtn) { moveBtn.disabled = false; moveBtn.innerHTML = ` drive_file_move Move User `; } throw error; } } private async handleDelete(): Promise { if ( !confirm(`Are you sure you want to delete this user?\n\n${this.userDn}`) ) { return; } const alertContainer = this.container.querySelector('#alert-container'); const deleteBtn = this.container.querySelector( '#delete-user-btn' ) as HTMLButtonElement; if (alertContainer) alertContainer.innerHTML = ''; if (deleteBtn) deleteBtn.disabled = true; try { await this.api.deleteUser(this.userDn); if (alertContainer) { alertContainer.innerHTML = `
check_circle
User deleted successfully!
`; } if (this.onDeleted) this.onDeleted(); } catch (error) { if (alertContainer) { alertContainer.innerHTML = `
error
${UserEditor.escapeHtml((error as Error).message)}
`; } if (deleteBtn) deleteBtn.disabled = false; } } async refresh(): Promise { await this.init(); } } linagora-ldap-rest-16e557e/src/browser/ldap-user-editor/components/UserList.ts000066400000000000000000000156541522642357000275240ustar00rootroot00000000000000/** * LDAP User Editor - User List Component * Shows users in a selected organization */ import type { LdapUser, Schema, Config } from '../types'; import type { UserApiClient } from '../api/UserApiClient'; export class UserList { private container: HTMLElement; private api: UserApiClient; private users: LdapUser[] = []; private orgDn: string; private searchQuery = ''; private onSelectUser: (dn: string) => void; private schema: Schema | null = null; private config: Config | null = null; constructor( container: HTMLElement, api: UserApiClient, orgDn: string, onSelectUser: (dn: string) => void ) { this.container = container; this.api = api; this.orgDn = orgDn; this.onSelectUser = onSelectUser; } async init(): Promise { // Load config and schema first this.config = await this.api.getConfig(); const usersResource = this.config.features?.ldapFlatGeneric?.flatResources?.find( r => r.pluralName === 'users' || r.name === 'users' ); if (usersResource?.schemaUrl) { this.schema = await this.api.getSchema(usersResource.schemaUrl); } this.render(); await this.loadUsers(); } private render(): void { this.container.innerHTML = `

people Users

`; const searchInput = this.container.querySelector( '#user-list-search-input' ) as HTMLInputElement; searchInput?.addEventListener('input', e => { this.searchQuery = (e.target as HTMLInputElement).value; this.renderUserList(); }); } private async loadUsers(): Promise { const listEl = this.container.querySelector('#user-list-items'); if (!listEl) return; try { listEl.innerHTML = '
'; // Use organization subnodes API to get users in this org // Filter by the first objectClass from schema to get only users (not groups/OUs) const baseUrl = this.api['baseUrl'] || window.location.origin; const objectClass = this.schema?.entity?.objectClass?.[1] || 'twakeAccount'; // Use second objectClass (not 'top') const res = await fetch( `${baseUrl}/api/v1/ldap/organizations/${encodeURIComponent(this.orgDn)}/subnodes?objectClass=${encodeURIComponent(objectClass)}` ); if (!res.ok) throw new Error('Failed to fetch organization users'); const items = (await res.json()) as LdapUser[]; // Filter only user entries using schema objectClass // An item is a user if it has ALL the expected objectClasses const expectedObjectClasses = this.schema?.entity?.objectClass || []; const mainAttribute = this.schema?.entity?.mainAttribute || ''; this.users = items.filter(item => { const objectClass = Array.isArray(item.objectClass) ? item.objectClass : [item.objectClass]; // Must have the mainAttribute field (uid, sAMAccountName, etc.) if (!item[mainAttribute]) return false; // Check if item has ALL the expected objectClasses return expectedObjectClasses.every(expected => objectClass.includes(expected) ); }); this.renderUserList(); } catch (error) { console.error('Failed to load users:', error); listEl.innerHTML = '
error

Failed to load users

'; } } private getFirstValue(value: unknown): string { if (!value) return ''; if (Array.isArray(value)) return value[0] || ''; return String(value); } private getFieldNameByRole(role: string): string | null { if (!this.schema) return null; const field = Object.entries(this.schema.attributes).find( ([, attr]) => attr.role === role ); return field ? field[0] : null; } private getFieldValueByRole(user: LdapUser, role: string): string { const fieldName = this.getFieldNameByRole(role); if (!fieldName) return ''; return this.getFirstValue(user[fieldName]); } private renderUserList(): void { const listEl = this.container.querySelector('#user-list-items'); if (!listEl) return; // Get field names from schema roles const displayNameField = this.getFieldNameByRole('displayName'); const identifierField = this.schema?.entity?.mainAttribute || ''; const emailField = this.getFieldNameByRole('primaryEmail'); // Filter users by search query const filteredUsers = this.searchQuery ? this.users.filter(user => { const displayName = displayNameField ? this.getFirstValue(user[displayNameField]).toLowerCase() : ''; const identifier = this.getFirstValue( user[identifierField] ).toLowerCase(); const email = emailField ? this.getFirstValue(user[emailField]).toLowerCase() : ''; const query = this.searchQuery.toLowerCase(); return ( displayName.includes(query) || identifier.includes(query) || email.includes(query) ); }) : this.users; if (filteredUsers.length === 0) { listEl.innerHTML = '
person_search

No users found

'; return; } listEl.innerHTML = filteredUsers .map(user => { const displayName = displayNameField ? this.getFirstValue(user[displayNameField]) : ''; const identifier = this.getFirstValue(user[identifierField]); const email = emailField ? this.getFirstValue(user[emailField]) : ''; const name = displayName || identifier || 'Unknown'; return `
person
${name}
${email ? `
${email}
` : ''}
`; }) .join(''); // Add click handlers listEl.querySelectorAll('.tree-node').forEach(node => { node.addEventListener('click', () => { const dn = node.getAttribute('data-dn'); if (dn) { this.onSelectUser(dn); } }); }); } async refresh(): Promise { await this.loadUsers(); } } linagora-ldap-rest-16e557e/src/browser/ldap-user-editor/components/UserTree.ts000066400000000000000000000133221522642357000274760ustar00rootroot00000000000000/** * LDAP User Editor - Organization Tree Component * Shows only LDAP organization tree (filters out users/groups) */ import type { LdapUser } from '../types'; import { CacheManager } from '../cache/CacheManager'; import { DisposableComponent } from '../../shared/components/DisposableComponent'; export class UserTree extends DisposableComponent { private container: HTMLElement; private baseUrl: string; private onSelectOrg: (dn: string) => void; private expandedNodes: Set = new Set(); private selectedDn: string | null = null; private rootDn: string | null = null; private cache: CacheManager; constructor( container: HTMLElement, baseUrl: string, onSelectOrg: (dn: string) => void ) { super(); this.container = container; this.baseUrl = baseUrl; this.onSelectOrg = onSelectOrg; this.cache = new CacheManager(); // Clean expired entries every 5 minutes - now properly cleaned up this.addManagedInterval(() => this.cache.cleanExpired(), 5 * 60 * 1000); } /** * Fetch with cache support */ private async cachedFetch(url: string): Promise { const cached = this.cache.get(url); if (cached !== null) { return cached; } const res = await fetch(url); if (!res.ok) { throw new Error(`HTTP ${res.status}: ${res.statusText}`); } const data = (await res.json()) as T; this.cache.set(url, data); return data; } async init(): Promise { this.container.innerHTML = ` `; try { // Load top organization const topOrg = await this.cachedFetch<{ dn: string }>( `${this.baseUrl}/api/v1/ldap/organizations/top` ); this.rootDn = topOrg.dn; await this.renderTree(); } catch (error) { console.error('Failed to init tree:', error); const treeEl = this.container.querySelector('#org-tree-viewer'); if (treeEl) { treeEl.innerHTML = '
error

Failed to load organization tree

'; } } } private async renderTree(): Promise { const treeEl = this.container.querySelector('#org-tree-viewer'); if (!treeEl || !this.rootDn) return; try { const html = await this.renderNode(this.rootDn, 0); treeEl.innerHTML = html; this.attachEventListeners(); } catch (error) { console.error('Failed to render tree:', error); treeEl.innerHTML = '
error

Error rendering tree

'; } } private async renderNode(dn: string, level: number): Promise { const isExpanded = this.expandedNodes.has(dn); const isSelected = this.selectedDn === dn; const indent = level * 20; // Get node info const node = await this.cachedFetch( `${this.baseUrl}/api/v1/ldap/organizations/${encodeURIComponent(dn)}` ); // Extract display name - handle both string and array let displayName = dn; if (node.ou) { displayName = Array.isArray(node.ou) ? node.ou[0] : node.ou; } else if (node.cn) { displayName = Array.isArray(node.cn) ? node.cn[0] : node.cn; } let html = `
${isExpanded ? 'expand_more' : 'chevron_right'} business ${displayName}
`; if (isExpanded) { try { // Load subnodes and filter only organizations const subnodes = await this.cachedFetch( `${this.baseUrl}/api/v1/ldap/organizations/${encodeURIComponent(dn)}/subnodes` ); const orgs = subnodes.filter((n: LdapUser) => { const classes = Array.isArray(n.objectClass) ? n.objectClass : n.objectClass ? [n.objectClass] : []; return ( classes.includes('organizationalUnit') || classes.includes('organization') ); }); for (const subnode of orgs) { html += await this.renderNode(subnode.dn, level + 1); } } catch (error) { console.error('Failed to load subnodes for', dn, error); } } return html; } private attachEventListeners(): void { // Toggle expand/collapse this.container.querySelectorAll('.tree-node-toggle').forEach(el => { el.addEventListener('click', async e => { e.stopPropagation(); const dn = el.getAttribute('data-dn'); if (!dn) return; if (this.expandedNodes.has(dn)) { this.expandedNodes.delete(dn); } else { this.expandedNodes.add(dn); } await this.renderTree(); }); }); // Select organization this.container.querySelectorAll('.tree-node-label').forEach(el => { el.addEventListener('click', e => { e.stopPropagation(); const dn = el.getAttribute('data-dn'); if (!dn) return; this.selectedDn = dn; this.renderTree(); this.onSelectOrg(dn); }); }); } async refresh(): Promise { await this.renderTree(); } override destroy(): void { this.container.innerHTML = ''; super.destroy(); } } linagora-ldap-rest-16e557e/src/browser/ldap-user-editor/index.ts000066400000000000000000000004111522642357000246550ustar00rootroot00000000000000/** * LDAP User Editor - Browser library * @author Xavier Guimard */ import './styles.css'; export { LdapUserEditor } from './LdapUserEditor'; export type { EditorOptions, LdapUser, Schema, SchemaAttribute, PointerOption, Config, } from './types'; linagora-ldap-rest-16e557e/src/browser/ldap-user-editor/styles.css000066400000000000000000000211321522642357000252360ustar00rootroot00000000000000/** * LDAP User Editor - Styles */ :root { --primary-color: #6200ee; --primary-dark: #3700b3; --success-color: #2e7d32; --error-color: #c62828; --bg-color: #f8fafc; --surface-color: #ffffff; --text-primary: #1e293b; --text-secondary: #64748b; --border-color: #e2e8f0; } .ldap-user-editor { font-family: 'Roboto', sans-serif; color: var(--text-primary); line-height: 1.6; width: 100%; min-width: 0; } .editor-layout { display: grid; grid-template-columns: minmax(300px, 1fr) minmax(300px, 2fr); gap: 24px; } @media (max-width: 900px) { .editor-layout { grid-template-columns: 1fr; } } .demo-panel { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); overflow: auto; max-height: 800px; min-width: 0; } .demo-panel h2 { font-size: 20px; font-weight: 500; margin-bottom: 16px; color: #333; display: flex; align-items: center; gap: 8px; } .demo-panel h2 .material-icons { color: var(--primary-color); } /* Sidebar */ .sidebar { display: flex; flex-direction: column; height: 100%; width: 100%; min-width: 0; } .sidebar-header { padding-bottom: 1rem; border-bottom: 1px solid var(--border-color); margin-bottom: 1rem; } .sidebar-header h2 { font-size: 1rem; font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; } .search-box { position: relative; } .search-box input { width: 100%; padding: 0.625rem 0.75rem 0.625rem 2.5rem; border: 1px solid var(--border-color); border-radius: 0.5rem; font-size: 0.875rem; transition: all 0.2s; } .search-box input:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); } .search-box .material-icons { position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); font-size: 1.25rem; color: var(--text-secondary); } .user-tree { flex: 1; overflow-y: auto; width: 100%; min-width: 0; } .tree-node { padding: 0.5rem; margin: 0.125rem 0; border-radius: 0.375rem; cursor: pointer; transition: all 0.15s; display: flex; align-items: center; gap: 0.5rem; min-width: 0; } .tree-node-toggle { cursor: pointer; font-size: 1.25rem; color: var(--text-secondary); user-select: none; flex-shrink: 0; width: 24px; height: 24px; } .tree-node-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .user-list-container { display: flex; flex-direction: column; height: 100%; } .user-list-header { margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color); } .user-list-header h2 { display: flex; align-items: center; gap: 0.5rem; font-size: 1rem; font-weight: 600; color: var(--text-primary); margin-bottom: 0.75rem; } .user-list { flex: 1; overflow-y: auto; } .tree-node:hover { background: var(--bg-color); } .tree-node.active { background: rgba(98, 0, 238, 0.1); color: var(--primary-color); font-weight: 500; } .tree-node .material-icons { font-size: 1.25rem; color: var(--text-secondary); flex-shrink: 0; width: 24px; height: 24px; } .tree-node.active .material-icons { color: var(--primary-color); } .tree-node-content { flex: 1; min-width: 0; display: flex; flex-direction: column; } .tree-node-name { font-size: 0.875rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tree-node-email { font-size: 0.75rem; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* Empty state */ .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 400px; color: #999; text-align: center; } .empty-state .material-icons { font-size: 48px; color: #ddd; margin-bottom: 8px; } .empty-state p { font-size: 1rem; font-style: italic; } /* Editor */ .editor-container { width: 100%; } .editor-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 2px solid #f0f0f0; } .editor-header .material-icons { font-size: 32px; color: #42a5f5; } .editor-title h3 { font-size: 18px; font-weight: 500; color: #333; } .editor-title .dn { font-size: 11px; color: #999; margin-top: 4px; } /* Form */ .editor-form { padding: 0; } .form-section { margin-bottom: 2rem; } .form-section:last-child { margin-bottom: 0; } .form-section-title { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: var(--text-primary); padding-bottom: 0.5rem; border-bottom: 2px solid var(--border-color); } .form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem; margin-bottom: 1.5rem; } .form-group { display: flex; flex-direction: column; } .form-label { font-size: 0.875rem; font-weight: 500; color: var(--text-primary); margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.25rem; } .form-label .required { color: var(--error-color); } .form-input, .form-select { padding: 0.625rem 0.75rem; border: 1px solid var(--border-color); border-radius: 0.5rem; font-size: 0.875rem; font-family: inherit; transition: all 0.2s; } .form-input:focus, .form-select:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(98, 0, 238, 0.1); } .form-input:disabled, .form-select:disabled { background: var(--bg-color); color: var(--text-secondary); cursor: not-allowed; } /* Array fields */ .array-field { display: flex; flex-direction: column; gap: 0.5rem; } .array-item { display: flex; gap: 0.5rem; } .array-item input, .array-item select { flex: 1; } /* Buttons */ .btn { padding: 10px 20px; border: none; border-radius: 4px; background: var(--primary-color); color: white; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s; text-transform: uppercase; letter-spacing: 0.5px; display: inline-flex; align-items: center; gap: 0.5rem; } .btn:hover:not(:disabled) { background: var(--primary-dark); } .btn-secondary { background: #ccc; color: #333; } .btn-secondary:hover:not(:disabled) { background: #aaa; } .btn:disabled { background: #ccc; cursor: not-allowed; } .btn-icon { padding: 0.5rem; width: 2.5rem; height: 2.5rem; display: inline-flex; align-items: center; justify-content: center; } .btn-icon .material-icons { font-size: 1.25rem; } .editor-actions { display: flex; gap: 0.75rem; } /* Alerts */ .alert { padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; } .alert-success { background: #e8f5e9; color: var(--success-color); } .alert-error { background: #ffebee; color: var(--error-color); } .alert .material-icons { font-size: 20px; } /* Loading */ .loading { text-align: center; padding: 3rem; color: var(--text-secondary); } .spinner { display: inline-block; width: 2rem; height: 2rem; border: 3px solid var(--border-color); border-top-color: var(--primary-color); border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Modal */ .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); display: none; /* Hidden by default */ align-items: center; justify-content: center; z-index: 1000; padding: 1rem; } .modal-overlay.active { display: flex; /* Show when active */ } .modal-container { background: white; border-radius: 8px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); width: 100%; max-width: 500px; max-height: 90vh; display: flex; flex-direction: column; } .modal-header { padding: 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; justify-content: space-between; } .modal-header h3 { font-size: 1.25rem; font-weight: 600; margin: 0; } .modal-close { background: none; border: none; padding: 0.5rem; cursor: pointer; color: var(--text-secondary); display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: background-color 0.2s; } .modal-close:hover { background-color: var(--bg-color); } .modal-body { padding: 1.5rem; overflow-y: auto; flex: 1; } .modal-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--border-color); display: flex; gap: 0.75rem; justify-content: flex-end; } linagora-ldap-rest-16e557e/src/browser/ldap-user-editor/types.ts000066400000000000000000000026431522642357000247230ustar00rootroot00000000000000/** * LDAP User Editor - Type definitions */ export interface EditorOptions { containerId: string; apiBaseUrl?: string; onUserSaved?: (userDn: string) => void; onError?: (error: Error) => void; } export interface LdapUser { dn: string; [key: string]: unknown; } export interface SchemaAttribute { type: string; required?: boolean; fixed?: boolean; role?: string; test?: string; branch?: string[]; items?: SchemaAttribute; default?: unknown; ui?: 'select' | 'search'; // For pointer fields: select (load all) or search (autocomplete) group?: string; // Group name for organizing fields in the UI } export interface Schema { entity: { name: string; mainAttribute: string; objectClass: string[]; singularName: string; pluralName: string; base: string; }; strict: boolean; attributes: Record; } export interface PointerOption { dn: string; label: string; } export interface FlatResourceConfig { name: string; singularName: string; pluralName: string; mainAttribute: string; objectClass: string[]; base: string; schema?: Schema; schemaUrl?: string; endpoints: { list: string; get: string; create: string; update: string; delete: string; }; } export interface Config { apiPrefix: string; ldapBase: string; features?: { ldapFlatGeneric?: { flatResources?: FlatResourceConfig[]; }; }; } linagora-ldap-rest-16e557e/src/browser/shared/000077500000000000000000000000001522642357000212705ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/shared/components/000077500000000000000000000000001522642357000234555ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/shared/components/DisposableComponent.ts000066400000000000000000000037431522642357000300040ustar00rootroot00000000000000/** * Base class for browser components with proper lifecycle management * Ensures all event listeners and intervals are cleaned up on destroy * @module browser/shared/components/DisposableComponent */ type EventListenerEntry = { target: EventTarget; type: string; listener: EventListenerOrEventListenerObject; options?: boolean | AddEventListenerOptions; }; export abstract class DisposableComponent { private _eventListeners: EventListenerEntry[] = []; private _intervals: ReturnType[] = []; private _isDestroyed = false; /** * Register an event listener for automatic cleanup * Use this instead of element.addEventListener directly */ protected addManagedEventListener( target: EventTarget, type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions ): void { if (this._isDestroyed) return; target.addEventListener(type, listener, options); this._eventListeners.push({ target, type, listener, options }); } /** * Register an interval for automatic cleanup * Use this instead of setInterval directly */ protected addManagedInterval( callback: () => void, ms: number ): ReturnType { const id = setInterval(callback, ms); this._intervals.push(id); return id; } /** * Check if the component has been destroyed */ protected get isDestroyed(): boolean { return this._isDestroyed; } /** * Clean up all managed resources * Override in subclasses but ALWAYS call super.destroy() */ destroy(): void { if (this._isDestroyed) return; this._isDestroyed = true; // Remove all event listeners for (const { target, type, listener, options } of this._eventListeners) { target.removeEventListener(type, listener, options); } this._eventListeners = []; // Clear all intervals for (const id of this._intervals) { clearInterval(id); } this._intervals = []; } } linagora-ldap-rest-16e557e/src/browser/shared/components/Modal.ts000066400000000000000000000100401522642357000250540ustar00rootroot00000000000000/** * Reusable Modal component * @module browser/shared/components/Modal */ import { escapeHtml, getElementByIdSafe } from '../utils/dom'; import { DisposableComponent } from './DisposableComponent'; export interface ModalOptions { id: string; title: string; onClose?: () => void; } export class Modal extends DisposableComponent { private overlay: HTMLElement | null = null; private options: ModalOptions; constructor(options: ModalOptions) { super(); this.options = options; this.overlay = getElementByIdSafe(options.id); if (!this.overlay) { throw new Error(`Modal overlay with id "${options.id}" not found`); } this.attachEventListeners(); } private attachEventListeners(): void { if (!this.overlay) return; // Close button const closeBtn = this.overlay.querySelector('.close-button'); if (closeBtn) { this.addManagedEventListener(closeBtn, 'click', () => this.close()); } // Cancel button const cancelBtn = this.overlay.querySelector('[data-modal-cancel]'); if (cancelBtn) { this.addManagedEventListener(cancelBtn, 'click', () => this.close()); } // Click outside to close this.addManagedEventListener(this.overlay, 'click', e => { if (e.target === this.overlay) { this.close(); } }); // Escape key to close - now properly cleaned up this.addManagedEventListener(document, 'keydown', e => { if ((e as KeyboardEvent).key === 'Escape' && this.isOpen()) { this.close(); } }); } open(): void { if (this.overlay) { this.overlay.classList.add('active'); document.body.style.overflow = 'hidden'; // Prevent background scroll } } close(): void { if (this.overlay) { this.overlay.classList.remove('active'); document.body.style.overflow = ''; // Restore scroll if (this.options.onClose) { this.options.onClose(); } } } /** * Clean up all event listeners and resources */ override destroy(): void { this.close(); super.destroy(); this.overlay = null; } isOpen(): boolean { return this.overlay?.classList.contains('active') || false; } getFormElement(): HTMLFormElement | null { return this.overlay?.querySelector('form') || null; } resetForm(): void { const form = this.getFormElement(); if (form) { form.reset(); } } setContent(html: string): void { const contentEl = this.overlay?.querySelector('[data-modal-content]'); if (contentEl) { contentEl.innerHTML = html; } } setTitle(title: string): void { const titleEl = this.overlay?.querySelector('.modal-header h2'); if (titleEl) { titleEl.textContent = title; } } /** * Create modal HTML structure */ static createModalHTML(options: { id: string; title: string; formId?: string; submitLabel?: string; }): string { const formId = options.formId || `${options.id}-form`; const submitLabel = options.submitLabel || 'Submit'; return ` `; } /** * Create and inject modal into DOM */ static create( options: ModalOptions & { formId?: string; submitLabel?: string; } ): Modal { const html = Modal.createModalHTML(options); document.body.insertAdjacentHTML('beforeend', html); return new Modal(options); } } linagora-ldap-rest-16e557e/src/browser/shared/components/StatusMessage.ts000066400000000000000000000065401522642357000266220ustar00rootroot00000000000000/** * Status message component for displaying notifications * @module browser/shared/components/StatusMessage */ import { escapeHtml, getElementByIdSafe } from '../utils/dom'; export type StatusType = 'error' | 'success' | 'info' | 'warning'; export interface StatusMessageOptions { containerId: string; duration?: number; // Duration in ms, 0 for persistent dismissible?: boolean; } export class StatusMessage { private container: HTMLElement | null = null; private options: StatusMessageOptions; private timeoutId: number | null = null; constructor(options: StatusMessageOptions) { this.options = { duration: 5000, dismissible: true, ...options, }; this.container = getElementByIdSafe(options.containerId); if (!this.container) { throw new Error( `Status container with id "${options.containerId}" not found` ); } } /** * Show a status message */ show(message: string, type: StatusType = 'info'): void { if (!this.container) return; // Clear any existing timeout if (this.timeoutId !== null) { window.clearTimeout(this.timeoutId); this.timeoutId = null; } const iconMap: Record = { error: 'error_outline', success: 'check_circle', info: 'info', warning: 'warning', }; const icon = iconMap[type] || 'info'; let html = `
${icon} ${escapeHtml(message)} `; if (this.options.dismissible) { html += ` `; } html += '
'; this.container.innerHTML = html; // Attach close button handler if (this.options.dismissible) { const closeBtn = this.container.querySelector('.status-message-close'); if (closeBtn) { closeBtn.addEventListener('click', () => this.hide()); } } // Auto-hide after duration if (this.options.duration && this.options.duration > 0) { this.timeoutId = window.setTimeout(() => { this.hide(); }, this.options.duration); } } /** * Show error message */ error(message: string): void { this.show(message, 'error'); } /** * Show success message */ success(message: string): void { this.show(message, 'success'); } /** * Show info message */ info(message: string): void { this.show(message, 'info'); } /** * Show warning message */ warning(message: string): void { this.show(message, 'warning'); } /** * Hide the status message */ hide(): void { if (this.container) { this.container.innerHTML = ''; } if (this.timeoutId !== null) { window.clearTimeout(this.timeoutId); this.timeoutId = null; } } /** * Get the container element */ getContainer(): HTMLElement | null { return this.container; } } /** * Simple functional API for showing status messages */ export function showStatus( containerId: string, message: string, type: StatusType = 'info', duration = 5000 ): void { const statusMessage = new StatusMessage({ containerId, duration }); statusMessage.show(message, type); } linagora-ldap-rest-16e557e/src/browser/shared/index.ts000066400000000000000000000007301522642357000227470ustar00rootroot00000000000000/** * Main export file for shared browser utilities and components * @module browser/shared */ // Utility exports export * from './utils/dom'; export * from './utils/form'; export * from './utils/schema'; export * from './utils/totp'; // Component exports export { Modal, type ModalOptions } from './components/Modal'; export { StatusMessage, showStatus, type StatusMessageOptions, } from './components/StatusMessage'; // Type exports export * from './types'; linagora-ldap-rest-16e557e/src/browser/shared/styles/000077500000000000000000000000001522642357000226135ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/shared/styles/common-demo.css000066400000000000000000000152061522642357000255430ustar00rootroot00000000000000/** * Common styles shared across all demo applications * Individual demos can override these with theme-specific styles */ /* Reset and base styles */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Roboto', sans-serif; min-height: 100vh; padding: 24px; } /* Demo container */ .demo-container { max-width: 1400px; margin: 0 auto; } /* Demo header */ .demo-header { background: white; color: #333; padding: 32px; margin-bottom: 24px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } .demo-header h1 { font-size: 32px; font-weight: 500; margin-bottom: 8px; } .demo-header p { font-size: 16px; color: #666; margin-bottom: 16px; } /* Action buttons */ .action-buttons, .demo-controls { display: flex; gap: 12px; margin-top: 16px; } .action-button, .demo-button { padding: 10px 20px; border: none; border-radius: 4px; color: white; cursor: pointer; font-size: 14px; font-weight: 500; display: flex; align-items: center; gap: 8px; transition: background 0.2s; text-transform: uppercase; letter-spacing: 0.5px; } .action-button:disabled, .demo-button:disabled { background: #ccc !important; cursor: not-allowed; } .action-button.danger { background: #d32f2f; } .action-button.danger:hover { background: #b71c1c; } /* Status messages */ .status-message { padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; animation: slideIn 0.3s ease-out; } @keyframes slideIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } .status-message.error { background: #ffebee; color: #c62828; } .status-message.success { background: #e8f5e9; color: #2e7d32; } .status-message.info { background: #e3f2fd; color: #1565c0; } .status-message.warning { background: #fff3e0; color: #e65100; } .status-message-text { flex: 1; } .status-message-close { background: none; border: none; cursor: pointer; padding: 4px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: background 0.2s; } .status-message-close:hover { background: rgba(0, 0, 0, 0.1); } .status-message-close .material-icons { font-size: 18px; } /* Modal overlay */ .modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); z-index: 1000; align-items: center; justify-content: center; } .modal-overlay.active { display: flex; } /* Modal */ .modal { background: white; border-radius: 8px; padding: 24px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); animation: modalSlideIn 0.3s ease-out; } @keyframes modalSlideIn { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } } .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } .modal-header h2 { font-size: 24px; font-weight: 500; color: #333; margin: 0; } .close-button { background: none; border: none; cursor: pointer; font-size: 24px; color: #666; padding: 0; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: background 0.2s; } .close-button:hover { background: #f0f0f0; } /* Form groups */ .form-group { margin-bottom: 20px; } .form-group label { display: block; margin-bottom: 8px; font-weight: 500; color: #333; } .form-group label.required::after { content: ' *'; color: #d32f2f; } .form-group input, .form-group textarea, .form-group select, .form-select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; font-family: 'Roboto', sans-serif; background-color: white; } .form-group textarea { min-height: 80px; resize: vertical; } .form-group input:focus, .form-group textarea:focus, .form-group select:focus, .form-select:focus { outline: none; border-color: var(--primary-color, #6200ee); } .form-select { cursor: pointer; } /* Help text */ .help-text { font-size: 12px; color: #666; margin-top: 4px; } /* Array fields */ .array-field { display: flex; flex-direction: column; gap: 8px; } .array-item { display: flex; gap: 8px; align-items: center; } /* Buttons */ .btn { padding: 8px 16px; border: none; border-radius: 4px; font-size: 14px; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; transition: background 0.2s; } .btn-secondary { background: #f0f0f0; color: #333; } .btn-secondary:hover { background: #e0e0e0; } .btn-icon { padding: 8px; min-width: auto; } .btn .material-icons { font-size: 18px; } /* Modal footer */ .modal-footer { display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px; } .modal-footer button { padding: 10px 20px; border: none; border-radius: 4px; font-size: 14px; font-weight: 500; cursor: pointer; transition: background 0.2s; } .modal-footer button.primary { background: var(--primary-color, #6200ee); color: white; } .modal-footer button.primary:hover { background: var(--primary-color-dark, #4a00b8); } .modal-footer button.secondary { background: #f0f0f0; color: #333; } .modal-footer button.secondary:hover { background: #e0e0e0; } /* Field groups */ .field-group { border: 1px solid #ddd; border-radius: 4px; padding: 16px; margin-bottom: 16px; background: #f9f9f9; } .field-group-title { font-weight: 500; color: var(--primary-color, #6200ee); margin-bottom: 12px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; } /* Demo grid */ .demo-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; } @media (max-width: 900px) { .demo-grid { grid-template-columns: 1fr; } } .demo-panel { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } .demo-panel h2 { font-size: 20px; font-weight: 500; margin-bottom: 16px; color: #333; display: flex; align-items: center; gap: 8px; } .demo-panel h2 .material-icons { color: var(--primary-color, #6200ee); } /* Utility classes */ .text-center { text-align: center; } .mt-0 { margin-top: 0; } .mt-1 { margin-top: 8px; } .mt-2 { margin-top: 16px; } .mt-3 { margin-top: 24px; } .mt-4 { margin-top: 32px; } .mb-0 { margin-bottom: 0; } .mb-1 { margin-bottom: 8px; } .mb-2 { margin-bottom: 16px; } .mb-3 { margin-bottom: 24px; } .mb-4 { margin-bottom: 32px; } linagora-ldap-rest-16e557e/src/browser/shared/types/000077500000000000000000000000001522642357000224345ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/shared/types/index.ts000066400000000000000000000043761522642357000241250ustar00rootroot00000000000000/** * Shared TypeScript types for browser components * @module browser/shared/types */ export interface EditorConfig { ldapBase?: string; features?: { flatResources?: ResourceConfig[]; }; [key: string]: unknown; } export interface ResourceConfig { name: string; pluralName?: string; schemaUrl?: string; schema?: SchemaDefinition; base?: string; } export interface SchemaDefinition { entity: { objectClass?: string[]; base?: string; }; attributes: Record; } export interface SchemaAttribute { type: 'string' | 'number' | 'integer' | 'array' | 'pointer'; required?: boolean; fixed?: boolean; default?: unknown; branch?: string[]; items?: { type: string; branch?: string[]; }; group?: string; } export interface PointerOption { dn: string; label: string; } export interface EditorAPI { getPointerOptions(branch: string): Promise; createEntry(dn: string, data: Record): Promise; deleteEntry(dn: string): Promise; updateEntry(dn: string, data: Record): Promise; } export interface BaseEditor { getConfig(): EditorConfig; getApi(): EditorAPI; getCurrentOrgDn(): string | null; getCurrentUserDn?(): string | null; init(): Promise; createUser?(data: Record): Promise; deleteUser?(dn: string): Promise; } export type StatusType = 'error' | 'success' | 'info' | 'warning'; export interface Theme { name: string; primaryColor: string; primaryColorDark: string; gradientStart: string; gradientEnd: string; } export const THEMES: Record = { purple: { name: 'Purple', primaryColor: '#6200ee', primaryColorDark: '#3700b3', gradientStart: '#667eea', gradientEnd: '#764ba2', }, blue: { name: 'Blue', primaryColor: '#185a9d', primaryColorDark: '#0d3a6b', gradientStart: '#43cea2', gradientEnd: '#185a9d', }, pink: { name: 'Pink', primaryColor: '#f5576c', primaryColorDark: '#d43d50', gradientStart: '#f093fb', gradientEnd: '#f5576c', }, green: { name: 'Green', primaryColor: '#00c853', primaryColorDark: '#009624', gradientStart: '#56ab2f', gradientEnd: '#a8e063', }, }; linagora-ldap-rest-16e557e/src/browser/shared/utils/000077500000000000000000000000001522642357000224305ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/browser/shared/utils/dom.ts000066400000000000000000000044451522642357000235660ustar00rootroot00000000000000/** * DOM utility functions * @module browser/shared/utils/dom */ /** * Escape HTML special characters to prevent XSS attacks * Reference: https://cheatsheetseries.owasp.org/cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.html */ export function escapeHtml(text: string | null | undefined): string { if (text == null) return ''; const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', }; return String(text).replace(/[&<>"'/]/g, m => map[m]); } /** * Convert camelCase or snake_case to Title Case */ export function toTitleCase(text: string): string { const label = text.replace(/([A-Z])/g, ' $1').replace(/_/g, ' '); return label.charAt(0).toUpperCase() + label.slice(1); } /** * Create an HTML element with attributes and children */ export function createElement( tag: string, attributes: Record = {}, children: (string | HTMLElement)[] = [] ): HTMLElement { const element = document.createElement(tag); for (const [key, value] of Object.entries(attributes)) { if (key === 'className') { element.className = value; } else { element.setAttribute(key, value); } } for (const child of children) { if (typeof child === 'string') { element.appendChild(document.createTextNode(child)); } else { element.appendChild(child); } } return element; } /** * Set innerHTML safely (assumes content has been properly escaped) */ export function setInnerHTML(element: HTMLElement, html: string): void { element.innerHTML = html; } /** * Get element by ID with type safety */ export function getElementByIdSafe( id: string ): T | null { return document.getElementById(id) as T | null; } /** * Query selector with type safety */ export function querySelectorSafe( selector: string, parent: Document | HTMLElement = document ): T | null { return parent.querySelector(selector) as T | null; } /** * Query selector all with type safety */ export function querySelectorAllSafe( selector: string, parent: Document | HTMLElement = document ): NodeListOf { return parent.querySelectorAll(selector) as NodeListOf; } linagora-ldap-rest-16e557e/src/browser/shared/utils/form.ts000066400000000000000000000215731522642357000237530ustar00rootroot00000000000000/** * Form utility functions * @module browser/shared/utils/form */ import { escapeHtml, toTitleCase } from './dom'; import type { SchemaAttribute, PointerOption } from '../types'; /** * Get user-friendly field label from field name */ export function getFieldLabel( fieldName: string, attribute?: SchemaAttribute, customLabels?: Record ): string { // Check custom labels first if (customLabels && customLabels[fieldName]) { return customLabels[fieldName]; } // Common label mappings const labelMap: Record = { ou: 'Unit Name', l: 'Location', telephoneNumber: 'Phone Number', facsimileTelephoneNumber: 'Fax Number', postalAddress: 'Postal Address', twakeDepartmentPath: 'Department Path', twakeLocalAdminLink: 'Local Administrators', twakeMailboxType: 'Mailbox Type', }; if (labelMap[fieldName]) { return labelMap[fieldName]; } // Convert to title case return toTitleCase(fieldName); } /** * Generate HTML for a form field based on schema attribute */ export async function generateFormField( fieldName: string, attribute: SchemaAttribute, options?: { customLabels?: Record; pointerOptionsLoader?: (branch: string) => Promise; } ): Promise { const label = getFieldLabel(fieldName, attribute, options?.customLabels); const required = attribute.required ? 'required' : ''; const labelClass = attribute.required ? 'required' : ''; let fieldHtml = ''; if (attribute.type === 'pointer') { // Pointer field - load options and display select const branch = attribute.branch?.[0]; if (branch && options?.pointerOptionsLoader) { const pointerOptions = await options.pointerOptionsLoader(branch); fieldHtml = `
${fieldName === 'twakeMailboxType' ? '
Select "mailingList" or "teamMailbox" to enable mail functionality
' : ''}
`; } else { // Fallback if no branch defined fieldHtml = `
Enter DN (Distinguished Name)
`; } } else if ( attribute.type === 'array' && attribute.items?.type === 'pointer' ) { // Array of pointers - will be handled dynamically const branch = attribute.items.branch?.[0]; if (branch) { fieldHtml = `
`; } else { // Fallback fieldHtml = `
Enter one DN per line
`; } } else if (attribute.type === 'array' && attribute.items?.type === 'string') { // Array field - use textarea fieldHtml = `
Enter one value per line
`; } else if (attribute.type === 'number' || attribute.type === 'integer') { // Number field fieldHtml = `
`; } else if (attribute.type === 'string') { // String field - use input with appropriate type const type = fieldName.toLowerCase().includes('password') || fieldName.toLowerCase().includes('pwd') ? 'password' : fieldName.toLowerCase().includes('mail') ? 'email' : fieldName.toLowerCase().includes('phone') || fieldName.toLowerCase().includes('telephone') ? 'tel' : 'text'; fieldHtml = `
`; } return fieldHtml; } /** * Process form data and convert types based on schema */ export function processFormData( formData: FormData, schema: Record ): Record { const data: Record = {}; // Collect form data for (const [key, value] of formData as any) { if (data[key]) { // Multiple values for the same key - convert to array if (!Array.isArray(data[key])) { data[key] = [data[key]]; } (data[key] as unknown[]).push(value); } else { data[key] = value; } } // Process special field types based on schema for (const [fieldName, attribute] of Object.entries(schema)) { if (!data[fieldName]) continue; if (attribute.type === 'array' && typeof data[fieldName] === 'string') { // Split by newlines and filter empty lines data[fieldName] = (data[fieldName] as string) .split('\n') .map(line => line.trim()) .filter(line => line.length > 0); } else if ( (attribute.type === 'number' || attribute.type === 'integer') && typeof data[fieldName] === 'string' ) { // Convert to number data[fieldName] = attribute.type === 'integer' ? parseInt(data[fieldName] as string, 10) : parseFloat(data[fieldName] as string); } } // Clean up: remove empty strings, null values, and empty arrays const cleanedData: Record = {}; for (const [key, value] of Object.entries(data)) { if (value === '' || value === null || value === undefined) { continue; } if (Array.isArray(value) && value.length === 0) { continue; } cleanedData[key] = value; } return cleanedData; } /** * Collect values from pointer array fields */ export function collectPointerArrayValues( container: HTMLElement ): Record { const values: Record = {}; const pointerArrayFields = container.querySelectorAll('.pointer-array-field'); pointerArrayFields.forEach(field => { const fieldName = field.getAttribute('data-field'); if (!fieldName) return; const selects = field.querySelectorAll( '.pointer-array-select' ) as NodeListOf; const fieldValues = Array.from(selects) .map(select => select.value) .filter(val => val); // Remove empty values if (fieldValues.length > 0) { values[fieldName] = fieldValues; } }); return values; } /** * Group schema fields by category */ export function groupSchemaFields( schema: Record, categorizer?: (fieldName: string, attribute: SchemaAttribute) => string | null ): Map { const groups = new Map(); for (const [fieldName, attribute] of Object.entries(schema)) { if (attribute.fixed) continue; if (fieldName === 'objectClass') continue; const category = categorizer ? categorizer(fieldName, attribute) : attribute.group || 'default'; if (category === null) continue; // Skip this field if (!groups.has(category)) { groups.set(category, []); } groups.get(category)!.push([fieldName, attribute]); } return groups; } linagora-ldap-rest-16e557e/src/browser/shared/utils/hmac.ts000066400000000000000000000154031522642357000237130ustar00rootroot00000000000000/** * HMAC-SHA256 request signing utility for browser * For backend services authentication * @module browser/shared/utils/hmac */ /** * HMAC Authentication configuration */ export interface HmacConfig { serviceId: string; // Service identifier secret: string; // Shared secret for HMAC } /** * Calculate SHA-256 hash of data */ async function sha256(data: string): Promise { const encoder = new TextEncoder(); const dataBuffer = encoder.encode(data); const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); } /** * Calculate HMAC-SHA256 signature */ async function hmacSha256(secret: string, message: string): Promise { const encoder = new TextEncoder(); const keyData = encoder.encode(secret); const messageData = encoder.encode(message); // Import secret as HMAC key const cryptoKey = await crypto.subtle.importKey( 'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); // Calculate HMAC const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData); const signatureArray = Array.from(new Uint8Array(signature)); return signatureArray.map(b => b.toString(16).padStart(2, '0')).join(''); } /** * Generate HMAC signature for a request * * Signature = HMAC-SHA256(secret, "METHOD|PATH|timestamp|body-hash") */ export async function generateHmacSignature( secret: string, method: string, path: string, timestamp: number, body?: unknown ): Promise { // Calculate body hash for POST/PATCH/PUT methods let bodyHash = ''; const methodUpper = method.toUpperCase(); if ( body && (methodUpper === 'POST' || methodUpper === 'PATCH' || methodUpper === 'PUT') ) { const bodyString = typeof body === 'string' ? body : JSON.stringify(body); bodyHash = await sha256(bodyString); } // Create signing string: METHOD|PATH|timestamp|body-hash const signingString = `${methodUpper}|${path}|${timestamp}|${bodyHash}`; // Calculate HMAC-SHA256 signature return await hmacSha256(secret, signingString); } /** * HMAC Authentication Client * Provides HTTP client with HMAC-SHA256 request signing */ export class HmacAuthClient { private config: HmacConfig; constructor(config: HmacConfig) { this.config = config; if (!this.config.serviceId) { throw new Error('Service ID is required'); } if (!this.config.secret || this.config.secret.length < 32) { throw new Error('Secret must be at least 32 characters long'); } } /** * Generate Authorization header for a request * * Format: HMAC-SHA256 service-id:timestamp:signature */ async getAuthHeader( method: string, path: string, body?: unknown ): Promise { const timestamp = Date.now(); const signature = await generateHmacSignature( this.config.secret, method, path, timestamp, body ); return `HMAC-SHA256 ${this.config.serviceId}:${timestamp}:${signature}`; } /** * Extract path with query string from URL */ private extractPath(url: string): string { try { const urlObj = new URL(url, window.location.origin); return urlObj.pathname + urlObj.search; } catch { // If URL parsing fails, assume it's already a path return url; } } /** * Perform an authenticated fetch request */ async fetch(url: string, options: RequestInit = {}): Promise { const method = options.method || 'GET'; const path = this.extractPath(url); // Get body for signature calculation let body: unknown = undefined; if (options.body) { if (typeof options.body === 'string') { body = options.body; } else if (options.body instanceof FormData) { // For FormData, we can't easily sign it, so skip body hash body = undefined; } else { body = options.body; } } const authHeader = await this.getAuthHeader(method, path, body); const headers = new Headers(options.headers); headers.set('Authorization', authHeader); return fetch(url, { ...options, headers, }); } /** * Prepare request body and headers for methods with body */ private prepareRequestBody( body: unknown, headers: Headers ): BodyInit | undefined { if (!body) { return undefined; } if (typeof body === 'string') { return body; } if (body instanceof FormData) { return body; } if (typeof body === 'object') { headers.set('Content-Type', 'application/json'); return JSON.stringify(body); } return undefined; } /** * Perform an authenticated GET request */ async get(url: string, options: RequestInit = {}): Promise { return this.fetch(url, { ...options, method: 'GET' }); } /** * Perform an authenticated POST request */ async post( url: string, body?: unknown, options: RequestInit = {} ): Promise { const headers = new Headers(options.headers); const requestBody = this.prepareRequestBody(body, headers); return this.fetch(url, { ...options, method: 'POST', headers, body: requestBody, }); } /** * Perform an authenticated PUT request */ async put( url: string, body?: unknown, options: RequestInit = {} ): Promise { const headers = new Headers(options.headers); const requestBody = this.prepareRequestBody(body, headers); return this.fetch(url, { ...options, method: 'PUT', headers, body: requestBody, }); } /** * Perform an authenticated DELETE request */ async delete(url: string, options: RequestInit = {}): Promise { return this.fetch(url, { ...options, method: 'DELETE' }); } /** * Perform an authenticated PATCH request */ async patch( url: string, body?: unknown, options: RequestInit = {} ): Promise { const headers = new Headers(options.headers); const requestBody = this.prepareRequestBody(body, headers); return this.fetch(url, { ...options, method: 'PATCH', headers, body: requestBody, }); } } /** * Verify the minimum recommended secret length */ export function isSecretSecure(secret: string): boolean { return secret.length >= 32; } /** * Generate a random secure secret for HMAC (for testing/development) * In production, secrets should be generated securely on the backend */ export async function generateRandomSecret( length: number = 64 ): Promise { const array = new Uint8Array(length); crypto.getRandomValues(array); return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); } linagora-ldap-rest-16e557e/src/browser/shared/utils/schema.ts000066400000000000000000000071731522642357000242500ustar00rootroot00000000000000/** * Schema utility functions * @module browser/shared/utils/schema */ import type { PointerOption } from '../types'; /** * Cache for pointer options to avoid repeated API calls */ const pointerOptionsCache = new Map(); /** * Load pointer options from API */ export async function loadPointerOptions( branch: string, apiLoader: (branch: string) => Promise, useCache = true ): Promise { if (useCache && pointerOptionsCache.has(branch)) { return pointerOptionsCache.get(branch)!; } try { const options = await apiLoader(branch); if (useCache) { pointerOptionsCache.set(branch, options); } return options; } catch (error) { console.error('Failed to load pointer options for branch:', branch, error); return []; } } /** * Clear pointer options cache */ export function clearPointerOptionsCache(): void { pointerOptionsCache.clear(); } /** * Replace placeholders in schema branches */ export function replacePlaceholders( value: string | string[], replacements: Record ): string | string[] { const replace = (str: string): string => { let result = str; for (const [key, val] of Object.entries(replacements)) { result = result.replace(new RegExp(key, 'g'), val); } return result; }; if (Array.isArray(value)) { return value.map(replace); } return replace(value); } /** * Replace placeholders in schema attributes */ export function replaceSchemaPlaceholders>( schema: T, replacements: Record ): T { const processValue = (value: unknown): unknown => { if (typeof value === 'string') { return replacePlaceholders(value, replacements); } if (Array.isArray(value)) { return value.map(processValue); } if (value && typeof value === 'object') { return processObject(value as Record); } return value; }; const processObject = ( obj: Record ): Record => { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { result[key] = processValue(value); } return result; }; return processObject(schema) as T; } /** * Fetch organization path from API */ export async function getOrganizationPath( orgDn: string, apiBaseUrl: string ): Promise { try { const response = await fetch( `${apiBaseUrl}/api/v1/ldap/organizations/${encodeURIComponent(orgDn)}` ); if (!response.ok) { console.error('Failed to fetch organization:', response.status); return orgDn; } const org = await response.json(); // Use twakeDepartmentPath if available (Twake schema) if (org.twakeDepartmentPath) { const pathValue = Array.isArray(org.twakeDepartmentPath) ? org.twakeDepartmentPath[0] : org.twakeDepartmentPath; return pathValue; } // Fallback to ou name for non-Twake schemas if (org.ou) { const ouValue = Array.isArray(org.ou) ? org.ou[0] : org.ou; return ouValue; } // Last resort: use DN return orgDn; } catch (error) { console.error('Failed to get organization info:', error); return orgDn; } } /** * Load schema from URL or object */ export async function loadSchema( schemaSource: string | T ): Promise { if (typeof schemaSource === 'string') { const response = await fetch(schemaSource); if (!response.ok) { throw new Error(`Failed to load schema from ${schemaSource}`); } return (await response.json()) as T; } return schemaSource; } linagora-ldap-rest-16e557e/src/browser/shared/utils/totp.ts000066400000000000000000000135021522642357000237670ustar00rootroot00000000000000/** * TOTP (Time-based One-Time Password) utility for browser * @module browser/shared/utils/totp */ /** * TOTP Generator configuration */ export interface TotpConfig { secret: string; // Base32 encoded secret digits?: number; // Number of digits (default: 6) step?: number; // Time step in seconds (default: 30) } /** * Generate a TOTP code from a Base32 secret */ export async function generateTotp(config: TotpConfig): Promise { const digits = config.digits ?? 6; const step = config.step ?? 30; // Get current time counter const now = Math.floor(Date.now() / 1000); const counter = Math.floor(now / step); return await generateTotpAtTime(config.secret, counter, digits); } /** * Generate a TOTP code for a specific counter value */ async function generateTotpAtTime( secret: string, counter: number, digits: number ): Promise { // Decode Base32 secret const key = base32Decode(secret); // Convert counter to 8-byte array (big-endian) const counterBytes = new Uint8Array(8); const view = new DataView(counterBytes.buffer); view.setBigUint64(0, BigInt(counter), false); // false = big-endian // Import key for HMAC-SHA1 const cryptoKey = await crypto.subtle.importKey( 'raw', key.buffer as ArrayBuffer, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'] ); // Generate HMAC-SHA1 const signature = await crypto.subtle.sign('HMAC', cryptoKey, counterBytes); const hash = new Uint8Array(signature); // Dynamic truncation (RFC 4226) const offset = hash[hash.length - 1] & 0x0f; const truncatedHash = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16) | ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff); // Generate N-digit code const code = truncatedHash % Math.pow(10, digits); return code.toString().padStart(digits, '0'); } /** * Decode a Base32 string to Uint8Array */ function base32Decode(input: string): Uint8Array { const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; // Remove trailing padding characters (avoid ReDoS by using simple loop instead of regex) let cleanInput = input.toUpperCase(); while (cleanInput.endsWith('=')) { cleanInput = cleanInput.slice(0, -1); } let bits = 0; let value = 0; const output: number[] = []; for (let i = 0; i < cleanInput.length; i++) { const idx = base32Chars.indexOf(cleanInput[i]); if (idx === -1) { throw new Error(`Invalid Base32 character: ${cleanInput[i]}`); } value = (value << 5) | idx; bits += 5; if (bits >= 8) { output.push((value >>> (bits - 8)) & 0xff); bits -= 8; } } return new Uint8Array(output); } /** * Validate if a string is valid Base32 */ export function isValidBase32(input: string): boolean { const base32Regex = /^[A-Z2-7]+=*$/i; return base32Regex.test(input); } /** * Get remaining seconds until next TOTP code */ export function getRemainingSeconds(step: number = 30): number { const now = Math.floor(Date.now() / 1000); return step - (now % step); } /** * TOTP Authentication Client * Provides HTTP client with TOTP authentication header */ export class TotpAuthClient { private config: Required; constructor(config: TotpConfig) { this.config = { secret: config.secret, digits: config.digits ?? 6, step: config.step ?? 30, }; if (!isValidBase32(this.config.secret)) { throw new Error('Invalid Base32 secret'); } } /** * Get current TOTP code */ async getCode(): Promise { return await generateTotp(this.config); } /** * Get Authorization header with Bearer token */ async getAuthHeader(): Promise { const code = await this.getCode(); return `Bearer ${code}`; } /** * Perform an authenticated fetch request */ async fetch(url: string, options: RequestInit = {}): Promise { const authHeader = await this.getAuthHeader(); const headers = new Headers(options.headers); headers.set('Authorization', authHeader); return fetch(url, { ...options, headers, }); } /** * Perform an authenticated GET request */ async get(url: string, options: RequestInit = {}): Promise { return this.fetch(url, { ...options, method: 'GET' }); } /** * Perform an authenticated POST request */ async post( url: string, body?: unknown, options: RequestInit = {} ): Promise { const headers = new Headers(options.headers); if (body && typeof body === 'object') { headers.set('Content-Type', 'application/json'); } return this.fetch(url, { ...options, method: 'POST', headers, body: body ? JSON.stringify(body) : undefined, }); } /** * Perform an authenticated PUT request */ async put( url: string, body?: unknown, options: RequestInit = {} ): Promise { const headers = new Headers(options.headers); if (body && typeof body === 'object') { headers.set('Content-Type', 'application/json'); } return this.fetch(url, { ...options, method: 'PUT', headers, body: body ? JSON.stringify(body) : undefined, }); } /** * Perform an authenticated DELETE request */ async delete(url: string, options: RequestInit = {}): Promise { return this.fetch(url, { ...options, method: 'DELETE' }); } /** * Perform an authenticated PATCH request */ async patch( url: string, body?: unknown, options: RequestInit = {} ): Promise { const headers = new Headers(options.headers); if (body && typeof body === 'object') { headers.set('Content-Type', 'application/json'); } return this.fetch(url, { ...options, method: 'PATCH', headers, body: body ? JSON.stringify(body) : undefined, }); } } linagora-ldap-rest-16e557e/src/config/000077500000000000000000000000001522642357000176045ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/config/args.ts000066400000000000000000000533101522642357000211120ustar00rootroot00000000000000/** * command-line options, corresponding environment variables, default values and types * Contains also the typescript declaration of config * @author Xavier Guimard */ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import type { AttributesList } from '../lib/ldapActions'; import type { ConfigTemplate } from '../lib/parseConfig'; export interface BranchPermissions { read?: boolean; write?: boolean; delete?: boolean; } export interface AuthConfig { default?: BranchPermissions; users?: { [uid: string]: { [branch: string]: BranchPermissions; }; }; groups?: { [groupDn: string]: { [branch: string]: BranchPermissions; }; }; } /** * Typescript declaration of config * * See below for config arguments, corresponding environment variables, * default value, type and optional plural name */ export interface Config { port: number; plugin?: string[]; schemas_path: string; log_level: 'error' | 'warn' | 'notice' | 'info' | 'debug'; logger: 'console'; api_prefix: string; mail_domain?: string[]; // LDAP ldap_base?: string; ldap_dn?: string; ldap_pwd?: string; ldap_url?: string[]; ldap_user_main_attribute?: string; ldap_cache_max?: number; ldap_cache_ttl?: number; ldap_pool_size?: number; ldap_connection_ttl?: number; user_class?: string[]; // LDAP groups plugin ldap_group_base?: string; ldap_groups_main_attribute?: string; group_class?: string[]; group_classes?: string[]; group_default_attributes?: AttributesList; groups_allow_unexistent_members?: boolean; group_dummy_user?: string; group_schema?: string; // LDAP Organizations plugin ldap_top_organization?: string; ldap_organization_class?: string[]; ldap_organization_link_attribute?: string; ldap_organization_path_attribute?: string; ldap_organization_path_separator?: string; ldap_organization_max_subnodes?: number; organization_schema?: string; // LDAP Flat generic plugin ldap_flat_schema?: string[]; // LDAP Bulk Import plugin bulk_import_schemas?: string; bulk_import_max_file_size?: string; bulk_import_batch_size?: string; // External users in groups external_members_branch?: string; external_branch_class?: string[]; // Static static_path?: string; static_name?: string; // auth/llng llng_ini?: string; // auth/token auth_token?: string[]; // auth/totp auth_totp?: string[]; auth_totp_window?: number; auth_totp_step?: number; // auth/hmac auth_hmac?: string[]; auth_hmac_window?: number; // auth/openidconnect oidc_server?: string; oidc_client_id?: string; oidc_client_secret?: string; base_url?: string; // auth/authzPerRoute authz_per_route?: string[]; // auth/authzPerBranch authz_per_branch_config?: AuthConfig; authz_per_branch_cache_ttl?: number; // auth/authzDynamic (LDAP-backed tokens + per-branch ACL) authz_dynamic_base?: string; authz_dynamic_cache_ttl?: number; authz_dynamic_token_attribute?: string; authz_dynamic_config_attribute?: string; authz_dynamic_tenant_attribute?: string; authz_dynamic_reload_endpoint?: boolean; // auth/authzLinid1 authz_local_admin_attribute?: string; // auth/rateLimit rate_limit_window_ms?: number; rate_limit_max?: number; // auth/crowdsec crowdsec_url?: string; crowdsec_api_key?: string; crowdsec_cache_ttl?: number; // auth/trustedProxy trusted_proxy?: string[]; trusted_proxy_auth_header?: string; // Special attributes mail_attribute?: string; quota_attribute?: string; delegation_attribute?: string; alias_attribute?: string; forward_attribute?: string; display_name_attribute?: string; drive_quota_attribute?: string; // James plugin james_webadmin_url?: string; james_webadmin_token?: string; james_signature_template?: string; ldap_concurrency?: number; james_concurrency?: number; james_init_delay?: number; james_mailing_list_branch?: string[]; james_mailbox_type_attribute?: string; // Drive (Cozy) plugin twake_drive_webadmin_url?: string; twake_drive_webadmin_token?: string; twake_drive_concurrency?: number; twake_drive_domain_attribute?: string; twake_drive_default_domain_template?: string; // Calendar Resources plugin calendar_webadmin_url?: string; calendar_webadmin_token?: string; calendar_concurrency?: number; calendar_resource_base?: string; calendar_resource_objectclass?: string; calendar_resource_creator?: string; calendar_resource_domain?: string; calendar_firstname_attribute?: string; calendar_lastname_attribute?: string; // Cozy provisioning plugin cozy_admin_url?: string; cozy_admin_user?: string; cozy_admin_passphrase?: string; cozy_org_id?: string; cozy_org_domain?: string; cozy_default_locale?: string; cozy_context_name?: string; cozy_apps?: string; cozy_auth_exchange?: string; cozy_b2b_exchange?: string; cozy_user_created_routing_key?: string; cozy_user_deleted_routing_key?: string; cloudery_manager_url?: string; cloudery_manager_token?: string; cloudery_offer?: string; cloudery_domain?: string; cloudery_user_branch?: string; cloudery_org_id_header?: string; cloudery_org_role_header?: string; cloudery_default_org_role?: string; cloudery_fqdn_attribute?: string; cloudery_org_id_attribute?: string; cloudery_org_role_attribute?: string; cloudery_phones_attribute?: string; cloudery_invited_attribute?: string; cloudery_default_locale?: string; cloudery_workflow_poll_interval_ms?: number; cloudery_workflow_max_attempts?: number; rabbitmq_url?: string; // Applicative Accounts plugin applicative_account_base?: string; max_app_accounts?: number; // LDAP attribute used to resolve the `:user` path param of the app-account // endpoints to the principal entry. Defaults to the mail attribute, which is // globally unique (see #88). Set to `uid` to restore the pre-#89 contract // where `:user` is the LDAP uid — only safe when uid is unique directory-wide. // Generated app-account uids are prefixed from this (unique) value, sanitized. app_accounts_user_attribute?: string; ldap_operational_attribute?: string[]; // Trash plugin trash_base?: string; trash_watched_bases?: string; trash_add_metadata?: string; trash_auto_create?: string; // Password Policy plugin ppolicy_default_dn?: string; ppolicy_warn_days?: number; ppolicy_validate_complexity?: boolean; ppolicy_min_length?: number; ppolicy_require_uppercase?: boolean; ppolicy_require_lowercase?: boolean; ppolicy_require_digit?: boolean; ppolicy_require_special?: boolean; ldap_users_base?: string; // SCIM plugin scim_prefix?: string; scim_user_base?: string; scim_group_base?: string; scim_user_base_template?: string; scim_group_base_template?: string; scim_user_base_header?: string; scim_group_base_header?: string; scim_base_header_root?: string; scim_base_map?: string; scim_user_object_class?: string[]; scim_user_rdn_attribute?: string; scim_group_object_class?: string[]; scim_group_rdn_attribute?: string; scim_id_attribute?: string; scim_user_mapping?: string; scim_group_mapping?: string; scim_max_results?: number; scim_bulk_max_operations?: number; scim_bulk_max_payload_size?: number; scim_etag?: boolean; scim_base_url?: string; // Accept additional config keys for non core plugins [key: string]: | string | string[] | boolean | number | AttributesList | AuthConfig | undefined; } /** * Config arguments * * Format: * [ command-line-option, env-variable, default-value, type?, plural? ] * * type can be one of: * - string (default value) * - boolean: * * --option is enough * * env variable must be set to "true" to be considered as truthy * - number * - json: parameter s a string that will be converted into an object during configuration parsing * * Additional command-line: * to permit to non-core plugin to use command-line, all command-line pairs `--key-name value` * are stored into config (string only) as `config.key_name = value` */ const configArgs: ConfigTemplate = [ // Global options ['--port', 'DM_PORT', 8081, 'number'], ['--plugin', 'DM_PLUGINS', [], 'array', '--plugins'], ['--log-level', 'DM_LOG_LEVEL', 'notice'], ['--logger', 'DM_LOGGER', 'console'], ['--api-prefix', 'DM_API_PREFIX', '/api'], ['--mail-domain', 'DM_MAIL_DOMAIN', [], 'array', '--mail-domains'], // LDAP options ['--ldap-base', 'DM_LDAP_BASE', ''], ['--ldap-dn', 'DM_LDAP_DN', 'cn=admin,dc=example,dc=com'], ['--ldap-pwd', 'DM_LDAP_PWD', 'admin'], ['--ldap-url', 'DM_LDAP_URL', ['ldap://localhost'], 'array', '--ldap-urls'], ['--ldap-user-main-attribute', 'DM_LDAP_USER_ATTRIBUTE', 'uid'], ['--ldap-cache-max', 'DM_LDAP_CACHE_MAX', 1000, 'number'], ['--ldap-cache-ttl', 'DM_LDAP_CACHE_TTL', 300, 'number'], // seconds ['--ldap-pool-size', 'DM_LDAP_POOL_SIZE', 5, 'number'], ['--ldap-connection-ttl', 'DM_LDAP_CONNECTION_TTL', 60, 'number'], // seconds [ '--schemas-path', 'DM_SCHEMAS_PATH', join( dirname(fileURLToPath(import.meta.url)), '..', '..', 'static', 'schemas' ), ], // Special attributes ['--mail-attribute', 'DM_MAIL_ATTRIBUTE', 'mail'], ['--quota-attribute', 'DM_QUOTA_ATTRIBUTE', 'mailQuota'], ['--delegation-attribute', 'DM_DELEGATION_ATTRIBUTE', 'twakeDelegatedUsers'], ['--alias-attribute', 'DM_ALIAS_ATTRIBUTE', 'mailAlternateAddress'], ['--forward-attribute', 'DM_FORWARD_ATTRIBUTE', 'mailForwardingAddress'], ['--display-name-attribute', 'DM_DISPLAY_NAME_ATTRIBUTE', 'displayName'], ['--drive-quota-attribute', 'DM_DRIVE_QUOTA_ATTRIBUTE', 'twakeDriveQuota'], // Default classes to insert into LDAP [ '--user-class', 'DM_USER_CLASSES', ['top', 'twakeAccount', 'twakeWhitePages'], 'array', '--user-classes', ], // Plugins options // LDAP organizations ['--ldap-top-organization', 'DM_LDAP_TOP_ORGANIZATION', ''], [ '--ldap-organization-class', 'DM_LDAP_ORGANIZATION_CLASSES', ['top', 'organizationalUnit', 'twakeDepartment'], 'array', '--ldap-organization-classes', ], [ '--ldap-organization-link-attribute', 'DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE', 'twakeDepartmentLink', ], [ '--ldap-organization-path-attribute', 'DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE', 'twakeDepartmentPath', ], [ '--ldap-organization-path-separator', 'DM_LDAP_ORGANIZATION_PATH_SEPARATOR', ' / ', ], [ '--ldap-organization-max-subnodes', 'DM_LDAP_ORGANIZATION_MAX_SUBNODES', 50, 'number', ], // LDAP groups plugin ['--ldap-group-base', 'DM_LDAP_GROUP_BASE', ''], ['--ldap-groups-main-attribute', 'DM_LDAP_GROUPS_MAIN_ATTRIBUTE', 'cn'], [ '--group-class', 'DM_GROUP_CLASSES', ['top', 'groupOfNames'], 'array', '--group-classes', ], [ '--group-allow-unexistent-members', 'DM_ALLOW_UNEXISTENT_MEMBERS', false, 'boolean', ], ['--group-default-attributes', 'DM_GROUP_DEFAULT_ATTRIBUTES', {}, 'json'], ['--group-dummy-user', 'DM_GROUP_DUMMY_USER', 'cn=fakeuser'], [ '--group-schema', 'DM_GROUP_SCHEMA', join( dirname(fileURLToPath(import.meta.url)), '..', '..', 'static', 'schemas', 'twake', 'groups.json' ), ], // externalUsersInGroups [ '--external-members-branch', 'DM_EXTERNAL_MEMBERS_BRANCH', 'ou=contacts,dc=example,dc=com', ], [ '--external-branch-class', 'DM_EXTERNAL_BRANCH_CLASSES', ['top', 'inetOrgPerson'], 'array', '--external-branch-classes', ], // static [ '--static-path', 'DM_STATIC_PATH', join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'static'), ], ['--static-name', 'DM_STATIC_NAME', 'static'], // LDAP Flat generic plugin [ '--ldap-flat-schema', 'DM_LDAP_FLAT_SCHEMA', [], 'array', '--ldap-flat-schemas', ], // LDAP Bulk Import plugin ['--bulk-import-schemas', 'DM_BULK_IMPORT_SCHEMAS', ''], ['--bulk-import-max-file-size', 'DM_BULK_IMPORT_MAX_FILE_SIZE', '10485760'], ['--bulk-import-batch-size', 'DM_BULK_IMPORT_BATCH_SIZE', '100'], // James plugin ['--james-webadmin-url', 'DM_JAMES_WEBADMIN_URL', 'http://localhost:8000'], ['--james-webadmin-token', 'DM_JAMES_WEBADMIN_TOKEN', ''], ['--james-signature-template', 'DM_JAMES_SIGNATURE_TEMPLATE', ''], ['--ldap-concurrency', 'DM_LDAP_CONCURRENCY', 10, 'number'], ['--james-concurrency', 'DM_JAMES_CONCURRENCY', 10, 'number'], ['--james-init-delay', 'DM_JAMES_INIT_DELAY', 1000, 'number'], [ '--james-mailing-list-branch', 'DM_JAMES_MAILING_LIST_BRANCHES', [], 'array', '--james-mailing-list-branches', ], [ '--james-mailbox-type-attribute', 'DM_JAMES_MAILBOX_TYPE_ATTRIBUTE', 'twakeMailboxType', ], // Drive (Cozy) plugin ['--twake-drive-webadmin-url', 'DM_TWAKE_DRIVE_WEBADMIN_URL', ''], ['--twake-drive-webadmin-token', 'DM_TWAKE_DRIVE_WEBADMIN_TOKEN', ''], ['--twake-drive-concurrency', 'DM_TWAKE_DRIVE_CONCURRENCY', 10, 'number'], [ '--twake-drive-domain-attribute', 'DM_TWAKE_DRIVE_DOMAIN_ATTRIBUTE', 'twakeCozyDomain', ], [ '--twake-drive-default-domain-template', 'DM_TWAKE_DRIVE_DEFAULT_DOMAIN_TEMPLATE', '', ], // Calendar Resources plugin [ '--calendar-webadmin-url', 'DM_CALENDAR_WEBADMIN_URL', 'http://localhost:8080', ], ['--calendar-webadmin-token', 'DM_CALENDAR_WEBADMIN_TOKEN', ''], ['--calendar-concurrency', 'DM_CALENDAR_CONCURRENCY', 10, 'number'], ['--calendar-resource-base', 'DM_CALENDAR_RESOURCE_BASE', ''], ['--calendar-resource-objectclass', 'DM_CALENDAR_RESOURCE_OBJECTCLASS', ''], ['--calendar-resource-creator', 'DM_CALENDAR_RESOURCE_CREATOR', ''], ['--calendar-resource-domain', 'DM_CALENDAR_RESOURCE_DOMAIN', ''], [ '--calendar-firstname-attribute', 'DM_CALENDAR_FIRSTNAME_ATTRIBUTE', 'givenName', ], ['--calendar-lastname-attribute', 'DM_CALENDAR_LASTNAME_ATTRIBUTE', 'sn'], // Cozy provisioning plugin (twake/cozyProvision) ['--cozy-admin-url', 'DM_COZY_ADMIN_URL', ''], ['--cozy-admin-user', 'DM_COZY_ADMIN_USER', 'admin'], ['--cozy-admin-passphrase', 'DM_COZY_ADMIN_PASSPHRASE', ''], ['--cozy-org-id', 'DM_COZY_ORG_ID', ''], ['--cozy-org-domain', 'DM_COZY_ORG_DOMAIN', ''], ['--cozy-default-locale', 'DM_COZY_DEFAULT_LOCALE', 'fr'], ['--cozy-context-name', 'DM_COZY_CONTEXT_NAME', 'default'], ['--cozy-apps', 'DM_COZY_APPS', 'home,drive,settings,notes,dataproxy'], ['--cozy-auth-exchange', 'DM_COZY_AUTH_EXCHANGE', 'auth'], ['--cozy-b2b-exchange', 'DM_COZY_B2B_EXCHANGE', 'b2b'], [ '--cozy-user-created-routing-key', 'DM_COZY_USER_CREATED_ROUTING_KEY', 'user.created', ], [ '--cozy-user-deleted-routing-key', 'DM_COZY_USER_DELETED_ROUTING_KEY', 'domain.user.deleted', ], // clouderyProvision plugin ['--cloudery-manager-url', 'DM_CLOUDERY_MANAGER_URL', ''], ['--cloudery-manager-token', 'DM_CLOUDERY_MANAGER_TOKEN', ''], ['--cloudery-offer', 'DM_CLOUDERY_OFFER', 'b2b_twake_default'], ['--cloudery-domain', 'DM_CLOUDERY_DOMAIN', ''], ['--cloudery-user-branch', 'DM_CLOUDERY_USER_BRANCH', ''], [ '--cloudery-org-id-header', 'DM_CLOUDERY_ORG_ID_HEADER', 'x-cloudery-org-id', ], [ '--cloudery-org-role-header', 'DM_CLOUDERY_ORG_ROLE_HEADER', 'x-cloudery-org-role', ], ['--cloudery-default-org-role', 'DM_CLOUDERY_DEFAULT_ORG_ROLE', 'member'], [ '--cloudery-fqdn-attribute', 'DM_CLOUDERY_FQDN_ATTRIBUTE', 'twakeWorkspaceUrl', ], [ '--cloudery-org-id-attribute', 'DM_CLOUDERY_ORG_ID_ATTRIBUTE', 'twakeOrganizationId', ], [ '--cloudery-org-role-attribute', 'DM_CLOUDERY_ORG_ROLE_ATTRIBUTE', 'twakeOrganizationRole', ], [ '--cloudery-phones-attribute', 'DM_CLOUDERY_PHONES_ATTRIBUTE', 'twakePhones', ], [ '--cloudery-invited-attribute', 'DM_CLOUDERY_INVITED_ATTRIBUTE', 'twakeInvited', ], ['--cloudery-default-locale', 'DM_CLOUDERY_DEFAULT_LOCALE', 'en'], [ '--cloudery-workflow-poll-interval-ms', 'DM_CLOUDERY_WORKFLOW_POLL_INTERVAL_MS', 2000, 'number', ], [ '--cloudery-workflow-max-attempts', 'DM_CLOUDERY_WORKFLOW_MAX_ATTEMPTS', 60, 'number', ], ['--rabbitmq-url', 'DM_RABBITMQ_URL', ''], // Applicative Accounts plugin ['--applicative-account-base', 'DM_APPLICATIVE_ACCOUNT_BASE', ''], ['--max-app-accounts', 'DM_MAX_APP_ACCOUNTS', 5, 'number'], [ '--ldap-operational-attribute', 'DM_LDAP_OPERATIONAL_ATTRIBUTES', [ 'dn', 'controls', 'structuralObjectClass', 'entryUUID', 'entryDN', 'subschemaSubentry', 'modifyTimestamp', 'modifiersName', 'createTimestamp', 'creatorsName', 'userPassword', ], 'array', '--ldap-operational-attributes', ], // Trash plugin ['--trash-base', 'DM_TRASH_BASE', ''], ['--trash-watched-bases', 'DM_TRASH_WATCHED_BASES', ''], ['--trash-add-metadata', 'DM_TRASH_ADD_METADATA', 'true'], ['--trash-auto-create', 'DM_TRASH_AUTO_CREATE', 'true'], /* Access control plugins */ // Lemonldap options ['--llng-ini', 'DM_LLNG_INI', '/etc/lemonldap-ng/lemonldap-ng.ini'], // Auth token plugin ['--auth-token', 'DM_AUTH_TOKENS', [], 'array', '--auth-tokens'], // Auth TOTP plugin ['--auth-totp', 'DM_AUTH_TOTP', [], 'array', '--auth-totps'], ['--auth-totp-window', 'DM_AUTH_TOTP_WINDOW', 1, 'number'], ['--auth-totp-step', 'DM_AUTH_TOTP_STEP', 30, 'number'], // Auth HMAC plugin ['--auth-hmac', 'DM_AUTH_HMAC', [], 'array', '--auth-hmacs'], ['--auth-hmac-window', 'DM_AUTH_HMAC_WINDOW', 120000, 'number'], // Auth authzPerRoute plugin [ '--authz-per-route', 'DM_AUTHZ_PER_ROUTES', [], 'array', '--authz-per-routes', ], // Auth authzPerBranch plugin [ '--authz-per-branch-config', 'DM_AUTHZ_PER_BRANCH_CONFIG', { default: { read: true, write: false, delete: false } } as AuthConfig, 'json', ], [ '--authz-per-branch-cache-ttl', 'DM_AUTHZ_PER_BRANCH_CACHE_TTL', 60, 'number', ], // Auth authzDynamic plugin ['--authz-dynamic-base', 'DM_AUTHZ_DYNAMIC_BASE', ''], ['--authz-dynamic-cache-ttl', 'DM_AUTHZ_DYNAMIC_CACHE_TTL', 60, 'number'], [ '--authz-dynamic-token-attribute', 'DM_AUTHZ_DYNAMIC_TOKEN_ATTRIBUTE', 'userPassword', ], [ '--authz-dynamic-config-attribute', 'DM_AUTHZ_DYNAMIC_CONFIG_ATTRIBUTE', 'description', ], [ '--authz-dynamic-tenant-attribute', 'DM_AUTHZ_DYNAMIC_TENANT_ATTRIBUTE', 'cn', ], [ '--authz-dynamic-reload-endpoint', 'DM_AUTHZ_DYNAMIC_RELOAD_ENDPOINT', false, 'boolean', ], // Auth authzLinid1 plugin [ '--authz-local-admin-attribute', 'DM_AUTHZ_LOCAL_ADMIN_ATTRIBUTE', 'twakeLocalAdminLink', ], // Auth OpenID Connect plugin ['--oidc-server', 'DM_OIDC_SERVER', ''], ['--oidc-client-id', 'DM_OIDC_CLIENT_ID', ''], ['--oidc-client-secret', 'DM_OIDC_CLIENT_SECRET', ''], ['--base-url', 'DM_BASE_URL', ''], // Rate limiting plugin [ '--rate-limit-window-ms', 'DM_RATE_LIMIT_WINDOW_MS', 15 * 60 * 1000, 'number', ], ['--rate-limit-max', 'DM_RATE_LIMIT_MAX', 100, 'number'], // CrowdSec plugin ['--crowdsec-url', 'DM_CROWDSEC_URL', 'http://localhost:8080/v1/decisions'], ['--crowdsec-api-key', 'DM_CROWDSEC_API_KEY', ''], ['--crowdsec-cache-ttl', 'DM_CROWDSEC_CACHE_TTL', 60, 'number'], // Trusted proxy plugin ['--trusted-proxy', 'DM_TRUSTED_PROXIES', [], 'array', '--trusted-proxies'], ['--trusted-proxy-auth-header', 'DM_TRUSTED_PROXY_AUTH_HEADER', 'Auth-User'], // Password Policy plugin ['--ppolicy-default-dn', 'DM_PPOLICY_DEFAULT_DN', ''], ['--ppolicy-warn-days', 'DM_PPOLICY_WARN_DAYS', 14, 'number'], [ '--ppolicy-validate-complexity', 'DM_PPOLICY_VALIDATE_COMPLEXITY', false, 'boolean', ], ['--ppolicy-min-length', 'DM_PPOLICY_MIN_LENGTH', 12, 'number'], [ '--ppolicy-require-uppercase', 'DM_PPOLICY_REQUIRE_UPPERCASE', true, 'boolean', ], [ '--ppolicy-require-lowercase', 'DM_PPOLICY_REQUIRE_LOWERCASE', true, 'boolean', ], ['--ppolicy-require-digit', 'DM_PPOLICY_REQUIRE_DIGIT', true, 'boolean'], ['--ppolicy-require-special', 'DM_PPOLICY_REQUIRE_SPECIAL', true, 'boolean'], ['--ldap-users-base', 'DM_LDAP_USERS_BASE', ''], // SCIM plugin ['--scim-prefix', 'DM_SCIM_PREFIX', '/scim/v2'], ['--scim-user-base', 'DM_SCIM_USER_BASE', ''], ['--scim-group-base', 'DM_SCIM_GROUP_BASE', ''], ['--scim-user-base-template', 'DM_SCIM_USER_BASE_TEMPLATE', ''], ['--scim-group-base-template', 'DM_SCIM_GROUP_BASE_TEMPLATE', ''], ['--scim-base-map', 'DM_SCIM_BASE_MAP', ''], ['--scim-user-base-header', 'DM_SCIM_USER_BASE_HEADER', ''], ['--scim-group-base-header', 'DM_SCIM_GROUP_BASE_HEADER', ''], ['--scim-base-header-root', 'DM_SCIM_BASE_HEADER_ROOT', ''], [ '--scim-user-object-class', 'DM_SCIM_USER_OBJECT_CLASSES', ['top', 'inetOrgPerson', 'organizationalPerson', 'person'], 'array', '--scim-user-object-classes', ], ['--scim-user-rdn-attribute', 'DM_SCIM_USER_RDN_ATTRIBUTE', 'uid'], [ '--scim-group-object-class', 'DM_SCIM_GROUP_OBJECT_CLASSES', ['top', 'groupOfNames'], 'array', '--scim-group-object-classes', ], ['--scim-group-rdn-attribute', 'DM_SCIM_GROUP_RDN_ATTRIBUTE', 'cn'], ['--scim-id-attribute', 'DM_SCIM_ID_ATTRIBUTE', 'rdn'], ['--scim-user-mapping', 'DM_SCIM_USER_MAPPING', ''], ['--scim-group-mapping', 'DM_SCIM_GROUP_MAPPING', ''], ['--scim-max-results', 'DM_SCIM_MAX_RESULTS', 200, 'number'], ['--scim-bulk-max-operations', 'DM_SCIM_BULK_MAX_OPERATIONS', 100, 'number'], [ '--scim-bulk-max-payload-size', 'DM_SCIM_BULK_MAX_PAYLOAD_SIZE', 1048576, 'number', ], ['--scim-etag', 'DM_SCIM_ETAG', false, 'boolean'], ['--scim-base-url', 'DM_SCIM_BASE_URL', ''], ]; export default configArgs; linagora-ldap-rest-16e557e/src/config/schema.ts000066400000000000000000000006361522642357000214210ustar00rootroot00000000000000import type { AttributeValue } from '../lib/ldapActions'; export interface Schema { strict: boolean; attributes: { [key: string]: { type: 'string' | 'array' | 'pointer'; items?: { type: string; test?: string | RegExp; }; default?: AttributeValue; required?: boolean; test?: string | RegExp; branch?: string[]; fixed?: boolean; }; }; } linagora-ldap-rest-16e557e/src/hooks.ts000066400000000000000000000125331522642357000200360ustar00rootroot00000000000000/** * Types for hooks * @author Xavier Guimard */ import type { SearchOptions, SearchResult } from 'ldapts'; import type { Request, Response } from 'express'; import type { ModifyRequest, AttributesList, AttributeValue, } from './lib/ldapActions'; import type { ChangesToNotify } from './plugins/ldap/onChange'; import * as utils from './lib/utils'; export type MaybePromise = Promise | T; export type ChainedHook = (arg: T) => MaybePromise; export type VoidHook = (...args: T) => MaybePromise; // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type export type OtherHook = Function; export { utils }; /** * All available hooks */ export interface Hooks { /** * Libraries */ /* LDAP */ // search ldapsearchopts?: ChainedHook; ldapsearchrequest?: ChainedHook<[string, SearchOptions, Request?]>; ldapsearchresult?: ChainedHook; // add ldapaddrequest?: ChainedHook<[string, AttributesList, Request?]>; ldapadddone?: (args: [string, AttributesList]) => MaybePromise; // modify ldapmodifyrequest?: ChainedHook<[string, ModifyRequest, number, Request?]>; ldapmodifydone?: ( args: [string, ModifyRequest, number] ) => MaybePromise; // delete ldapdeleterequest?: ChainedHook<[string | string[], Request?]>; ldapdeletedone?: (dn: string | string[]) => MaybePromise; // rename ldaprenamerequest?: ChainedHook<[string, string, Request?]>; ldaprenamedone?: (args: [string, string]) => MaybePromise; /** * Plugins */ /** Demo plugin */ hello?: () => string; /** LdapGroups plugin */ ldapgroupvalidatemembers?: ChainedHook<[string, string[]]>; ldapgroupadd?: ChainedHook<[string, AttributesList]>; ldapgroupadddone?: (args: [string, AttributesList]) => MaybePromise; // the number given as 3rd argument is a uniq operation number // It can be used to save state before modify and launch the // real hook after change but with previous value ldapgroupmodify?: ChainedHook<[string, ModifyRequest, number]>; ldapgroupmodifydone?: ( args: [string, ModifyRequest, number] ) => MaybePromise; ldapgroupdelete?: ChainedHook; ldapgroupdeletedone?: (dn: string) => MaybePromise; ldapgroupaddmember?: ChainedHook<[string, string[]]>; ldapgroupdeletemember?: ChainedHook<[string, string[]]>; // this hook is for low-level ldap listGroups method _ldapgrouplist?: ChainedHook>; /** "onLdapChange" */ onLdapChange?: (dn: string, changes: ChangesToNotify) => MaybePromise; onLdapMailChange?: ( dn: string, oldMail: AttributeValue | null, newMail: AttributeValue | null ) => MaybePromise; onLdapAliasChange?: ( dn: string, mail: string, oldAliases: string[], newAliases: string[] ) => MaybePromise; onLdapQuotaChange?: ( dn: string, mail: string, oldQuota: number, newQuota: number ) => MaybePromise; onLdapForwardChange?: ( dn: string, mail: string, oldForwards: string[], newForwards: string[] ) => MaybePromise; onLdapDisplayNameChange?: ( dn: string, oldDisplayName: string | null, newDisplayName: string | null ) => MaybePromise; onLdapDriveQuotaChange?: ( dn: string, oldDriveQuota: number | null, newDriveQuota: number | null ) => MaybePromise; /** externalUsersInGroup */ externaluserentry?: ChainedHook<[string, AttributesList]>; externaluseradded?: (dn: string, mail: string) => MaybePromise; /** * Generic flat resource move hooks * Pattern: {hookPrefix}move * Examples: ldapusermove, ldappositionmove, etc. * * move: before moving - can modify target or cancel * Note: After move, onLdapChange is triggered automatically by modifyEntry() */ // Note: These are defined dynamically via the index signature below // but documented here for reference: // - ldapusermove?: ChainedHook<[string, string, Request?]> // [dn, targetOrgDn, req] // External hooks (allows dynamic hook names like ldapusermove, ldapgroupmove, etc.) [K: string]: | ChainedHook | VoidHook | OtherHook | undefined; /** Common authentication hooks */ beforeAuth?: ChainedHook<[Request, Response]>; afterAuth?: ChainedHook<[Request, Response]>; /** Organization hooks */ getOrganisationTop?: ChainedHook< [Request | undefined, AttributesList | null] >; /** * SCIM plugin hooks (typed dynamically via the index signature above) * * - scimusercreate: ChainedHook<[ScimUser, Request?]> — pre-create, can mutate * - scimusercreatedone: VoidHook<[ScimUser]> — post-create * - scimuserupdate: ChainedHook<[string, ScimUser, Request?]> * - scimuserupdatedone: VoidHook<[string, ScimUser]> * - scimuserdelete: ChainedHook<[string, Request?]> * - scimuserdeletedone: VoidHook<[string]> * - scimgroupcreate: ChainedHook<[ScimGroup, Request?]> * - scimgroupcreatedone: VoidHook<[ScimGroup]> * - scimgroupupdate: ChainedHook<[string, ScimGroup, Request?]> * - scimgroupupdatedone: VoidHook<[string, ScimGroup]> * - scimgroupdelete: ChainedHook<[string, Request?]> * - scimgroupdeletedone: VoidHook<[string]> * - scimbulkdone: VoidHook<[BulkResponse]> */ } linagora-ldap-rest-16e557e/src/lib/000077500000000000000000000000001522642357000171055ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/lib/README.md000066400000000000000000000000261522642357000203620ustar00rootroot00000000000000# Available libraries linagora-ldap-rest-16e557e/src/lib/auth/000077500000000000000000000000001522642357000200465ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/lib/auth/base.ts000066400000000000000000000022411522642357000213270ustar00rootroot00000000000000import type { Express, Request, Response } from 'express'; export type DmRequest = Request & { user?: string }; // eslint-disable-next-line import/order import DmPlugin from '../../abstract/plugin'; import { serverError } from '../../lib/expressFormatedResponses'; import { launchHooksChained } from '../../lib/utils'; export default abstract class AuthBase extends DmPlugin { abstract authMethod(req: DmRequest, res: Response, next: () => void): void; api(app: Express): void { app.use(async (req, res, next) => { try { [req, res] = await launchHooksChained(this.server.hooks.beforeAuth, [ req, res, ]); } catch (err) { return serverError(res, err as Error); } // eslint-disable-next-line @typescript-eslint/no-misused-promises this.authMethod(req, res, async (): Promise => { try { if (this.hooks?.onAuth) { [req, res] = await launchHooksChained(this.server.hooks.afterAuth, [ req, res, ]); } next(); } catch (err) { serverError(res, err as Error); } }); }); } } linagora-ldap-rest-16e557e/src/lib/authz/000077500000000000000000000000001522642357000202405ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/lib/authz/base.ts000066400000000000000000000274131522642357000215310ustar00rootroot00000000000000/** * @module lib/authz/base * @author Xavier Guimard * * Base class for authorization plugins * Provides common functionality for permission-based access control * @group Libraries */ import type { SearchOptions } from 'ldapts'; import DmPlugin, { type Role } from '../../abstract/plugin'; import type { BranchPermissions } from '../../config/args'; import type { DmRequest } from '../auth/base'; import type { AttributesList, ModifyRequest, SearchResult, } from '../ldapActions'; import { getParentDn } from '../utils'; /** * Abstract base class for authorization plugins * Provides common utility methods and interface for LDAP-based authorization */ export default abstract class AuthzBase extends DmPlugin { roles: Role[] = ['authz'] as const; cacheTTL!: number; /** * Extract the branch DN to check permissions against * For a DN like "uid=user,ou=users,ou=org,dc=example,dc=com" * we need to check permissions on the parent branch * * Handles escaped commas in DN values (e.g., "cn=Smith\, John") */ extractBranchDn(dn: string): string { return getParentDn(dn); } /** * Resolve user identifier to the format expected by getUserPermissions * This allows different implementations: * - authzLinid1: converts uid to userDn via LDAP * - authzPerBranch: uses uid directly * * Returns null if user cannot be resolved (will skip authorization) */ abstract resolveUser(uid: string): Promise; /** * Get user's permissions for a specific branch * Must be implemented by subclasses */ abstract getUserPermissions( user: string, branch: string ): Promise; /** * Get list of branches user has access to (for read permission by default) * Must be implemented by subclasses */ abstract getAuthorizedBranches(user: string): Promise; /** * Check if authorization should be skipped for this request * Can be overridden by subclasses for custom logic */ protected shouldSkipAuthorization(req?: DmRequest): boolean { return !req?.user; } /** * Common hooks for all authorization plugins */ hooks = { ldapmodifyrequest: async ([dn, changes, opNumber, req]: [ string, ModifyRequest, number, DmRequest?, ]): Promise<[string, ModifyRequest, number, DmRequest?]> => { if (this.shouldSkipAuthorization(req)) { return [dn, changes, opNumber, req]; } const user = await this.resolveUser(req!.user!); if (!user) { this.logger.warn(`User ${req!.user} could not be resolved`); return [dn, changes, opNumber, req]; } // Check if this is a move operation (changing organization link) const linkAttr = this.config.ldap_organization_link_attribute; if (linkAttr && changes.replace?.[linkAttr]) { // For move operations, we need to check: // 1. Read permission on the source (current location) // 2. Write permission on the destination (new location) // First, check read permission on source // Get the current entry to find its current organization link try { const currentEntry = (await this.server.ldap.search( { paged: false, scope: 'base', attributes: [linkAttr] }, dn )) as SearchResult; if (currentEntry.searchEntries.length > 0) { const currentLink = currentEntry.searchEntries[0][linkAttr]; const sourceBranch = Array.isArray(currentLink) ? String(currentLink[0]) : String(currentLink); const sourcePermissions = await this.getUserPermissions( user, sourceBranch ); if (!sourcePermissions.read) { throw new Error( `[authz-forbidden] User ${req!.user} does not have read permission for source branch ${sourceBranch}` ); } } // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (err) { // If we can't read the current entry, check permissions on the entry's parent branch const sourceBranch = this.extractBranchDn(dn); const sourcePermissions = await this.getUserPermissions( user, sourceBranch ); if (!sourcePermissions.read) { throw new Error( `[authz-forbidden] User ${req!.user} does not have read permission for source branch ${sourceBranch}` ); } } // Then check write permission on destination const newLink = changes.replace[linkAttr]; const destBranch = Array.isArray(newLink) ? String(newLink[0]) : String(newLink); const destPermissions = await this.getUserPermissions(user, destBranch); if (!destPermissions.write) { throw new Error( `[authz-forbidden] User ${req!.user} does not have write permission for destination branch ${destBranch}` ); } } else { // For other modifications, check write permission on the entry's current branch const branchToCheck = this.extractBranchDn(dn); const permissions = await this.getUserPermissions(user, branchToCheck); if (!permissions.write) { throw new Error( `[authz-forbidden] User ${req!.user} does not have write permission for branch ${branchToCheck}` ); } } return [dn, changes, opNumber, req]; }, ldapaddrequest: async ([dn, entry, req]: [ string, AttributesList, DmRequest?, ]): Promise<[string, AttributesList, DmRequest?]> => { if (this.shouldSkipAuthorization(req)) { return [dn, entry, req]; } const user = await this.resolveUser(req!.user!); if (!user) { this.logger.warn(`User ${req!.user} could not be resolved`); return [dn, entry, req]; } // Determine which branch to check permissions for let branchToCheck: string; // If the entry has an organization link, check permissions for that organization const linkAttr = this.config.ldap_organization_link_attribute; if (linkAttr && entry[linkAttr]) { const linkValue = entry[linkAttr]; branchToCheck = Array.isArray(linkValue) ? String(linkValue[0]) : String(linkValue); } else { // For organizations (ou entries) or entries without link, check the parent DN branchToCheck = this.extractBranchDn(dn); } const permissions = await this.getUserPermissions(user, branchToCheck); // Check write permission if (!permissions.write) { throw new Error( `[authz-forbidden] User ${req!.user} does not have write permission for branch ${branchToCheck}` ); } return [dn, entry, req]; }, ldapsearchrequest: async ([base, opts, req]: [ string, SearchOptions, DmRequest?, ]): Promise<[string, SearchOptions, DmRequest?]> => { if (this.shouldSkipAuthorization(req)) { return [base, opts, req]; } const user = await this.resolveUser(req!.user!); if (!user) { this.logger.warn(`User ${req!.user} could not be resolved`); return [base, opts, req]; } // Allow base scope search on top organization (for getOrganisationTop) if (base === this.config.ldap_top_organization && opts.scope === 'base') { return [base, opts, req]; } const permissions = await this.getUserPermissions(user, base); // Check read permission if (!permissions.read) { throw new Error( `[authz-forbidden] User ${req!.user} does not have read permission for branch ${base}` ); } return [base, opts, req]; }, getOrganisationTop: async ([req, defaultTop]: [ DmRequest | undefined, AttributesList | null, ]): Promise<[DmRequest | undefined, AttributesList | null]> => { // If no user, return default if (this.shouldSkipAuthorization(req as DmRequest)) { return [req, defaultTop]; } const user = await this.resolveUser((req as DmRequest).user!); if (!user) { return [req, defaultTop]; } // Get authorized branches for this user const authorizedBranches = await this.getAuthorizedBranches(user); // If user has specific authorized branches, return them as top organizations if (authorizedBranches.length > 0) { const orgs: AttributesList[] = []; for (const branch of authorizedBranches) { try { const result = await this.server.ldap.search( { paged: false, scope: 'base' }, branch, req ); if ((result as SearchResult).searchEntries.length === 1) { orgs.push((result as SearchResult).searchEntries[0]); } } catch (err) { this.logger.warn( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Failed to fetch authorized branch ${branch}: ${err}` ); } } if (orgs.length === 1) { return [req, orgs[0]]; } else if (orgs.length > 1) { // Return the first one - subclass can override this behavior return [req, orgs[0]]; } } // Return default if no authorized branches return [req, defaultTop]; }, ldaprenamerequest: async ([oldDn, newDn, req]: [ string, string, DmRequest?, ]): Promise<[string, string, DmRequest?]> => { if (this.shouldSkipAuthorization(req)) { return [oldDn, newDn, req]; } const user = await this.resolveUser(req!.user!); if (!user) { this.logger.warn(`User ${req!.user} could not be resolved`); return [oldDn, newDn, req]; } // For rename/move operations, we need to check: // 1. Read permission on the source (current location) // 2. Write permission on the destination (new location) // Extract source and destination branches const sourceBranch = this.extractBranchDn(oldDn); const destBranch = this.extractBranchDn(newDn); // Check read permission on source const sourcePermissions = await this.getUserPermissions( user, sourceBranch ); if (!sourcePermissions.read) { throw new Error( `[authz-forbidden] User ${req!.user} does not have read permission for source branch ${sourceBranch}` ); } // Check write permission on destination const destPermissions = await this.getUserPermissions(user, destBranch); if (!destPermissions.write) { throw new Error( `[authz-forbidden] User ${req!.user} does not have write permission for destination branch ${destBranch}` ); } return [oldDn, newDn, req]; }, ldapdeleterequest: async ([dn, req]: [ string | string[], DmRequest?, ]): Promise<[string | string[], DmRequest?]> => { if (this.shouldSkipAuthorization(req)) { return [dn, req]; } const user = await this.resolveUser(req!.user!); if (!user) { this.logger.warn(`User ${req!.user} could not be resolved`); return [dn, req]; } // Check delete permission on the branch of every target entry. const targets = Array.isArray(dn) ? dn : [dn]; for (const target of targets) { const branchToCheck = this.extractBranchDn(target); const permissions = await this.getUserPermissions(user, branchToCheck); if (!permissions.delete) { throw new Error( `[authz-forbidden] User ${req!.user} does not have delete permission for branch ${branchToCheck}` ); } } return [dn, req]; }, }; } linagora-ldap-rest-16e557e/src/lib/errors.ts000066400000000000000000000037211522642357000207740ustar00rootroot00000000000000/** * Custom HTTP Error classes with status codes * @author Xavier Guimard */ /** * Base HTTP Error class with status code */ export class HttpError extends Error { constructor( message: string, public statusCode: number = 500 ) { super(message); this.name = 'HttpError'; } } /** * Client errors (4xx) */ export class BadRequestError extends HttpError { constructor(message = 'Bad request') { super(message, 400); this.name = 'BadRequestError'; } } export class UnauthorizedError extends HttpError { constructor(message = 'Unauthorized') { super(message, 401); this.name = 'UnauthorizedError'; } } export class ForbiddenError extends HttpError { constructor(message = 'Forbidden') { super(message, 403); this.name = 'ForbiddenError'; } } export class NotFoundError extends HttpError { constructor(message = 'Not found') { super(message, 404); this.name = 'NotFoundError'; } } export class ConflictError extends HttpError { constructor(message = 'Conflict') { super(message, 409); this.name = 'ConflictError'; } } export class UriTooLongError extends HttpError { constructor(message = 'URI Too Long') { super(message, 414); this.name = 'UriTooLongError'; } } export class TooManyRequestsError extends HttpError { constructor(message = 'Too Many Requests') { super(message, 429); this.name = 'TooManyRequestsError'; } } /** * Server errors (5xx) */ export class BadGatewayError extends HttpError { constructor(message = 'Bad Gateway') { super(message, 502); this.name = 'BadGatewayError'; } } export class ServiceUnavailableError extends HttpError { constructor(message = 'Service Unavailable') { super(message, 503); this.name = 'ServiceUnavailableError'; } } export class GatewayTimeoutError extends HttpError { constructor(message = 'Gateway Timeout') { super(message, 504); this.name = 'GatewayTimeoutError'; } } linagora-ldap-rest-16e557e/src/lib/expressFormatedResponses.ts000066400000000000000000000126261522642357000245410ustar00rootroot00000000000000/** * Standard express responses and express utilities * @author Xavier Guimard */ import type { Request, Response } from 'express'; import type winston from 'winston'; let _logger: winston.Logger; export const setLogger = (logger: winston.Logger): void => { _logger = logger; }; export const getLogger = (): winston.Logger => _logger; // Utility that generates standard responses depending on the success of the method export const tryMethod = async ( res: Response, // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type method: Function, ...args: unknown[] ): Promise => { try { // eslint-disable-next-line @typescript-eslint/no-unsafe-call await method(...args); return ok(res); } catch (err) { return serverError(res, err); } }; export const tryMethodData = async ( res: Response, // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type method: Function, ...args: unknown[] ): Promise => { try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call const data = await method(...args); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument return ok(res, data); } catch (err) { return serverError(res, err); } }; /** * Standard API responses with default message */ const _rejectResponse = ( code: number, res: Response, message: string ): void => { _logger?.warn(`Client error ${code}: ${message}`); res.status(code).json({ error: message }); }; // 20x responses export const ok = (res: Response, data: object = { success: true }): void => { res.status(200).json(data); }; export const created = (res: Response, data?: object): void => { res.status(201).json(data); }; export const noContent = (res: Response): void => { res.status(204).send(); }; // 40x responses export const badRequest = (res: Response, message = 'Bad request'): void => _rejectResponse(400, res, message); export const unauthorized = (res: Response, message = 'Unauthorized'): void => _rejectResponse(401, res, message); export const forbidden = (res: Response, message = 'Forbidden'): void => _rejectResponse(403, res, message); export const notFound = (res: Response, message = 'Not found'): void => _rejectResponse(404, res, message); export const conflict = (res: Response, message = 'Conflict'): void => _rejectResponse(409, res, message); export const uriTooLong = (res: Response, message = 'URI Too Long'): void => _rejectResponse(414, res, message); export const tooManyRequests = ( res: Response, message = 'Too Many Requests' ): void => _rejectResponse(429, res, message); // 50x responses // We don't want to publish the real error in server responses export const serverError = (res: Response, err: unknown): void => { const message = err instanceof Error ? err.message : String(err); let statusCode = err instanceof Error && 'statusCode' in err ? (err as { statusCode: number }).statusCode : 500; // Upstream plugins sometimes wrap errors as `new Error(\`...: ${err}\`)`, // dropping any `statusCode` the original carried. Authz plugins can embed // `[authz-forbidden]` in the thrown message so the final serializer still // knows to emit a proper 403 instead of falling back to 500. if (statusCode === 500 && /\[authz-forbidden\]/.test(message)) { statusCode = 403; } // Client error (4xx) - log as warning and return error message if (statusCode >= 400 && statusCode < 500) { const clientMessage = statusCode === 403 && /\[authz-forbidden\]/.test(message) ? 'Token does not have permission on this branch' : message; _logger.warn(`Client error ${statusCode}: ${clientMessage}`); res.status(statusCode).json({ error: clientMessage }); return; } // Server error (5xx) - log as error and hide details if (err instanceof Error) { _logger.error(err.message, err); } else if (typeof err === 'string') { _logger.error(err); } else { _logger.error('Server error', err); } res.status(statusCode).json({ error: 'check logs' }); }; export const badGateway = (res: Response, message = 'Bad Gateway'): void => _rejectResponse(502, res, message); export const serviceUnavailable = ( res: Response, message = 'Service unavailable' ): void => _rejectResponse(503, res, message); export const gatewayTimeout = ( res: Response, message = 'Gateway Timeout' ): void => _rejectResponse(504, res, message); // Utilities export const wantJson = (req: Request, res: Response): boolean => { if (req.accepts('json') === false) { badRequest(res); return false; } return true; }; export const jsonBody = ( req: Request, res: Response, ...requiredFields: string[] ): object | false => { try { if (!wantJson(req, res)) return false; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const body = req.body; if (requiredFields.length > 0) { for (let i = 0; i < requiredFields.length; i++) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (body[requiredFields[i]] == undefined) { badRequest(res, 'Bad content'); return false; } } } // eslint-disable-next-line @typescript-eslint/no-unsafe-return return body; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { badRequest(res, 'Bad content'); return false; } }; linagora-ldap-rest-16e557e/src/lib/ldapActions.ts000066400000000000000000000536041522642357000217260ustar00rootroot00000000000000/** * LDAP low-level library * @author Xavier Guimard */ import type { Request } from 'express'; import { Client, Attribute, Change } from 'ldapts'; import type { ClientOptions, SearchResult, SearchOptions } from 'ldapts'; import type winston from 'winston'; import { LRUCache } from 'lru-cache'; import pLimit from 'p-limit'; import { type Config } from '../config/args'; import { type DM } from '../bin'; import { escapeDnValue, launchHooks, launchHooksChained } from './utils'; // Typescript interface // Entry export type AttributeValue = Buffer | Buffer[] | string[] | string; export type AttributesList = Record; export type LdapList = Record; // Connection pool entry interface PooledConnection { client: Client; createdAt: number; inUse: boolean; } // search const defaultSearchOptions: SearchOptions = { scope: 'sub', filter: '(objectClass=*)', attributes: ['*'], sizeLimit: 0, timeLimit: 10, paged: { pageSize: 100, }, }; export type { SearchOptions, SearchResult }; // modify export interface ModifyRequest { add?: AttributesList; replace?: AttributesList; delete?: string[] | AttributesList; } // Code class ldapActions { config: Config; options: ClientOptions; dn: string; pwd: string; base: string; parent: DM; logger: winston.Logger; private searchCache: LRUCache; public queryLimit: ReturnType; private connectionPool: PooledConnection[] = []; private poolSize: number; private connectionTtl: number; // in milliseconds private ldapUrls: string[]; private currentUrlIndex: number = 0; // LRU cache for attribute signatures to prevent unbounded memory growth private attrSignatureCache: LRUCache; private waitingResolvers: Array<(conn: PooledConnection) => void> = []; private availableConnections: PooledConnection[] = []; private isCleaningUp = false; constructor(server: DM) { this.parent = server; this.logger = server.logger; this.config = server.config; // Initialize connection pool settings this.poolSize = this.config.ldap_pool_size || 5; this.connectionTtl = (this.config.ldap_connection_ttl || 60) * 1000; // Convert to ms this.logger.info( `LDAP connection pool initialized: size=${this.poolSize}, ttl=${this.connectionTtl / 1000}s` ); // Initialize global LDAP query concurrency limiter const concurrency = this.config.ldap_concurrency || 10; this.queryLimit = pLimit(concurrency); this.logger.info( `Global LDAP query concurrency limit set to ${concurrency}` ); // Initialize LRU cache for search results const cacheMax: number = typeof this.config.ldap_cache_max === 'string' ? parseInt(this.config.ldap_cache_max, 10) || 1000 : (this.config.ldap_cache_max ?? 1000); const cacheTtl = (this.config.ldap_cache_ttl || 300) * 1000; // Convert seconds to ms this.searchCache = new LRUCache({ max: cacheMax, ttl: cacheTtl, updateAgeOnGet: false, updateAgeOnHas: false, }); this.logger.info( `LDAP search cache initialized: max=${cacheMax}, ttl=${cacheTtl / 1000}s` ); // Initialize bounded LRU cache for attribute signatures this.attrSignatureCache = new LRUCache({ max: 1000, // Reasonable limit for attribute signature combinations }); if (!server.config.ldap_url || server.config.ldap_url.length === 0) { throw new Error('LDAP URL is not defined'); } if (!server.config.ldap_dn) { throw new Error('LDAP DN is not defined'); } if (!server.config.ldap_pwd) { throw new Error('LDAP password is not defined'); } if (!server.config.ldap_base) { this.base = server.config.ldap_dn.split(',', 2)[1]; this.logger.warn(`LDAP base is not defined, using "${this.base}"`); } else { this.base = server.config.ldap_base; } this.ldapUrls = server.config.ldap_url; this.logger.info( `LDAP failover configured with ${this.ldapUrls.length} URL(s): ${this.ldapUrls.join(', ')}` ); this.options = { url: this.ldapUrls[0], timeout: 0, connectTimeout: 0, strictDN: false, }; if (this.ldapUrls[0].startsWith('ldaps://')) { this.options.tlsOptions = { minVersion: 'TLSv1.2', }; } this.dn = server.config.ldap_dn; this.pwd = server.config.ldap_pwd; } /** * Create a new LDAP connection with failover support */ private async createConnection(): Promise { const errors: Error[] = []; // Try each URL in order for (let i = 0; i < this.ldapUrls.length; i++) { const urlIndex = (this.currentUrlIndex + i) % this.ldapUrls.length; const url = this.ldapUrls[urlIndex]; try { this.logger.debug(`Attempting connection to ${url}`); const options: ClientOptions = { ...this.options, url, tlsOptions: url.startsWith('ldaps://') ? { minVersion: 'TLSv1.2' } : undefined, }; const client: Client = new Client(options); await client.bind(this.dn, this.pwd); // Connection successful if (urlIndex !== this.currentUrlIndex) { this.logger.info( `LDAP failover: switched from ${this.ldapUrls[this.currentUrlIndex]} to ${url}` ); this.currentUrlIndex = urlIndex; } return client; } catch (error) { this.logger.warn(`Failed to connect to ${url}: ${String(error)}`); errors.push(error as Error); } } // All URLs failed this.logger.error( `LDAP connection failed for all ${this.ldapUrls.length} URL(s)` ); throw new Error( `LDAP connection failed for all URLs: ${errors.map(e => e.message).join(', ')}` ); } /** * Clean up expired connections from the pool * Uses a flag to prevent concurrent cleanup operations */ private cleanupExpiredConnections(): void { // Prevent concurrent cleanup operations if (this.isCleaningUp) return; this.isCleaningUp = true; try { const now = Date.now(); const expired: PooledConnection[] = []; // Clean from availableConnections queue (only these can expire as they're not in use) for (let i = this.availableConnections.length - 1; i >= 0; i--) { const conn = this.availableConnections[i]; if (now - conn.createdAt > this.connectionTtl) { expired.push(conn); this.availableConnections.splice(i, 1); // Also remove from main pool const poolIdx = this.connectionPool.indexOf(conn); if (poolIdx !== -1) { this.connectionPool.splice(poolIdx, 1); } } } // Unbind expired connections asynchronously for (const conn of expired) { void conn.client.unbind().catch(err => { this.logger.debug( `Error unbinding expired connection: ${String(err)}` ); }); } if (expired.length > 0) { this.logger.debug( `Cleaned up ${expired.length} expired LDAP connections` ); } } finally { this.isCleaningUp = false; } } /** * Acquire a connection from the pool or create a new one * Optimized for O(1) lookup using separate available connections queue */ private async acquireConnection(): Promise { // Clean up expired connections this.cleanupExpiredConnections(); // O(1) - Try to pop from available connections queue if (this.availableConnections.length > 0) { const conn = this.availableConnections.pop()!; conn.inUse = true; this.logger.debug('Reusing pooled LDAP connection'); return conn; } // If pool is not full, create a new connection if (this.connectionPool.length < this.poolSize) { const client = await this.createConnection(); const pooled: PooledConnection = { client, createdAt: Date.now(), inUse: true, }; this.connectionPool.push(pooled); this.logger.debug( `Created new LDAP connection (pool: ${this.connectionPool.length}/${this.poolSize})` ); return pooled; } // Pool is full, wait for an available connection using Promise with timeout this.logger.debug( 'LDAP connection pool full, waiting for available connection' ); return new Promise((resolve, reject) => { const timeoutId = globalThis.setTimeout(() => { const idx = this.waitingResolvers.indexOf(resolveWrapper); if (idx !== -1) this.waitingResolvers.splice(idx, 1); reject(new Error('LDAP connection pool timeout after 30s')); }, 30000); const resolveWrapper = (conn: PooledConnection) => { globalThis.clearTimeout(timeoutId); resolve(conn); }; this.waitingResolvers.push(resolveWrapper); }); } /** * Release a connection back to the pool * If there are waiting requests, hand off the connection directly */ private releaseConnection(pooled: PooledConnection): void { // Check if connection has expired before reusing if (Date.now() - pooled.createdAt > this.connectionTtl) { const idx = this.connectionPool.indexOf(pooled); if (idx !== -1) this.connectionPool.splice(idx, 1); void pooled.client.unbind().catch(() => {}); this.logger.debug('Released expired LDAP connection (discarded)'); return; } // If there are waiting requests, give them the connection directly (O(1)) if (this.waitingResolvers.length > 0) { const resolve = this.waitingResolvers.shift()!; // Connection stays in use, just transfer ownership this.logger.debug('Released LDAP connection to waiting request'); resolve(pooled); return; } // No waiters, mark as available pooled.inUse = false; this.availableConnections.push(pooled); this.logger.debug('Released LDAP connection back to pool'); } /** * Get a sorted signature for attribute list, using cache for performance */ private getAttributeSignature(attributes: string[] | undefined): string { if (!attributes || attributes.length === 0) return '*'; // Use array as-is for cache key (common patterns repeat) const key = attributes.join('|'); let sig = this.attrSignatureCache.get(key); if (!sig) { sig = [...attributes].sort().join(','); this.attrSignatureCache.set(key, sig); } return sig; } /** * Generate cache key for LDAP search */ private getCacheKey(base: string, opts: SearchOptions): string { // Create a deterministic cache key from base DN and search options const sortedAttrs = this.getAttributeSignature(opts.attributes); const filterStr = typeof opts.filter === 'string' ? opts.filter : opts.filter ? opts.filter.toString() : '(objectClass=*)'; return `${base}:${opts.scope || 'sub'}:${filterStr}:${sortedAttrs}`; } /** * Invalidate cache entries for a specific DN * Called after modifications to ensure cache consistency */ invalidateCache(dn: string): void { // Remove all cache entries that match this DN for (const key of this.searchCache.keys()) { if (key.startsWith(dn)) { this.searchCache.delete(key); } } } /* LDAP search */ async search( options: SearchOptions, base: string = this.base, req?: Request ): Promise> { let opts = { ...defaultSearchOptions, ...options, }; opts = await launchHooksChained(this.parent.hooks.ldapsearchopts, opts); [base, opts] = await launchHooksChained( this.parent.hooks.ldapsearchrequest, [base, opts, req] ); // Check cache for non-paginated, base-scope searches only // These are the most common for attribute lookups if (!opts.paged && opts.scope === 'base') { const cacheKey = this.getCacheKey(base, opts); const cached = this.searchCache.get(cacheKey); if (cached) { this.logger.debug(`LDAP search cache hit: ${cacheKey}`); return cached; } } // Acquire connection from pool const pooled = await this.acquireConnection(); try { let res = opts.paged ? pooled.client.searchPaginated(base, opts) : pooled.client.search(base, opts); res = (await launchHooksChained( this.parent.hooks.ldapsearchresult, res )) as typeof res; // Cache non-paginated, base-scope search results if (!opts.paged && opts.scope === 'base' && res instanceof Promise) { const result = await res; const cacheKey = this.getCacheKey(base, opts); this.searchCache.set(cacheKey, result); this.logger.debug(`LDAP search cached: ${cacheKey}`); return result; } // For paginated searches, return a wrapped generator that releases connection when done if (opts.paged) { return this.wrapPaginatedSearch( res as AsyncGenerator, pooled ); } return res; } finally { // For non-paginated searches, release connection immediately if (!opts.paged) { this.releaseConnection(pooled); } } } /** * Wrap paginated search to ensure connection is released when done */ private async *wrapPaginatedSearch( generator: AsyncGenerator, pooled: PooledConnection ): AsyncGenerator { try { for await (const result of generator) { yield result; } } finally { this.releaseConnection(pooled); } } /* LDAP add */ async add( dn: string, entry: AttributesList, req?: Request ): Promise { dn = this.setDn(dn); if ( (!entry.objectClass || entry.objectClass.length === 0) && this.config.user_class ) { entry.objectClass = this.config.user_class; } // Convert Buffer/Buffer[] values to string/string[] const sanitizedEntry: Record = {}; for (const [key, value] of Object.entries(entry)) { if (Buffer.isBuffer(value)) { sanitizedEntry[key] = value.toString(); } else if ( Array.isArray(value) && value.length > 0 && Buffer.isBuffer(value[0]) ) { sanitizedEntry[key] = (value as Buffer[]).map(v => v.toString()); } else { sanitizedEntry[key] = value as string | string[]; } } [dn, entry] = (await launchHooksChained(this.parent.hooks.ldapaddrequest, [ dn, sanitizedEntry, req, ])) as [string, typeof entry, Request?]; // Convert to Attribute objects const attributes: Attribute[] = []; for (const [key, value] of Object.entries(sanitizedEntry)) { const values = Array.isArray(value) ? value : [value]; attributes.push( new Attribute({ type: key, values, }) ); } const pooled = await this.acquireConnection(); try { await pooled.client.add(dn, attributes); // Invalidate cache for this DN this.invalidateCache(dn); void launchHooks(this.parent.hooks.ldapadddone, [dn, entry]).catch( err => { this.logger.error(`Hook ldapadddone failed: ${String(err)}`); } ); return true; } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`LDAP add error: ${error}`); } finally { this.releaseConnection(pooled); } } /* LDAP modify */ async modify( dn: string, changes: ModifyRequest, req?: Request ): Promise { dn = this.setDn(dn); const ldapChanges: Change[] = []; const op: number = this.opNumber(); [dn, changes] = await launchHooksChained( this.parent.hooks.ldapmodifyrequest, [dn, changes, op, req] ); if (changes.add) { for (const [key, value] of Object.entries(changes.add)) { ldapChanges.push( new Change({ operation: 'add', modification: new Attribute({ type: key, values: Array.isArray(value) ? value : [value as string], }), }) ); } } if (changes.replace) { for (const [key, value] of Object.entries(changes.replace)) { ldapChanges.push( new Change({ operation: 'replace', modification: new Attribute({ type: key, values: Array.isArray(value) ? value : [value as string], }), }) ); } } if (changes.delete) { if (Array.isArray(changes.delete)) { for (const attr of changes.delete) { if (attr) ldapChanges.push( new Change({ operation: 'delete', modification: new Attribute({ type: attr, values: [], }), }) ); } } else { for (const [key, value] of Object.entries(changes.delete)) { const change = new Change({ operation: 'delete', modification: value ? new Attribute({ type: key, values: Array.isArray(value) ? (value as string[]) : [value as string], }) : new Attribute({ type: key }), }); ldapChanges.push(change); } } } if (ldapChanges.length !== 0) { const pooled = await this.acquireConnection(); try { await pooled.client.modify(dn, ldapChanges); // Invalidate cache for this DN this.invalidateCache(dn); void launchHooks(this.parent.hooks.ldapmodifydone, [ dn, changes, op, ]).catch(err => { this.logger.error(`Hook ldapmodifydone failed: ${String(err)}`); }); return true; } catch (error) { this.logger.warn( `Changes that failed: ${dn}, ${JSON.stringify(ldapChanges)}` ); // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`LDAP modify error: ${error}`); } finally { this.releaseConnection(pooled); } } else { this.logger.error('No changes to apply'); void launchHooks(this.parent.hooks.ldapmodifydone, [dn, {}, op]).catch( err => { this.logger.error(`Hook ldapmodifydone failed: ${String(err)}`); } ); return false; } } async rename(dn: string, newRdn: string, req?: Request): Promise { dn = this.setDn(dn); newRdn = this.setDn(newRdn); [dn, newRdn] = await launchHooksChained( this.parent.hooks.ldaprenamerequest, [dn, newRdn, req] ); const pooled = await this.acquireConnection(); try { await pooled.client.modifyDN(dn, newRdn); void launchHooks(this.parent.hooks.ldaprenamedone, [dn, newRdn]).catch( err => { this.logger.error(`Hook ldaprenamedone failed: ${String(err)}`); } ); return true; } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`LDAP rename error: ${error}`); } finally { this.releaseConnection(pooled); } } /** * Move an entry to a new location (different parent) * Uses LDAP modifyDN with full DN to change both RDN and parent * * Note: ldapts provides a high-level API that accepts a full DN as the second parameter, * unlike the standard LDAP modifyDN which expects (newRDN, deleteOldRDN, newSuperior). * ldapts automatically parses the full DN and extracts the newRDN and newSuperior components * before sending the proper LDAP modifyDN request to the server. * * @param dn - Current DN (e.g., "uid=user1,ou=users,dc=example,dc=com") * @param newDn - Full new DN (e.g., "uid=user1,ou=trash,dc=example,dc=com") * ldapts will extract newRDN="uid=user1" and newSuperior="ou=trash,dc=example,dc=com" */ async move(dn: string, newDn: string): Promise { dn = this.setDn(dn); newDn = this.setDn(newDn); const pooled = await this.acquireConnection(); try { await pooled.client.modifyDN(dn, newDn); this.logger.debug(`LDAP move: ${dn} -> ${newDn}`); return true; } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`LDAP move error: ${error}`); } finally { this.releaseConnection(pooled); } } /* LDAP delete */ async delete(dn: string | string[], req?: Request): Promise { if (Array.isArray(dn)) { dn = dn.map(d => this.setDn(d)); } else { dn = this.setDn(dn); } if (!Array.isArray(dn)) dn = [dn]; [dn] = (await launchHooksChained(this.parent?.hooks.ldapdeleterequest, [ dn, req, ])) as [string | string[], Request?]; const pooled = await this.acquireConnection(); try { for (const entry of dn) { try { await pooled.client.del(entry); // Invalidate cache for this DN this.invalidateCache(entry); } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`LDAP delete error: ${error}`); } void launchHooks(this.parent.hooks.ldapdeletedone, entry).catch(err => { this.logger.error(`Hook ldapdeletedone failed: ${String(err)}`); }); } return true; } finally { this.releaseConnection(pooled); } } private setDn(dn: string): string { if (!/=/.test(dn)) { dn = `${this.config.ldap_user_main_attribute as string}=${escapeDnValue(dn)},${this.base}`; } else if (!/,/.test(dn)) { dn += `,${this.base}`; } return dn; } opNumber(): number { return this.parent.operationSequence++; } } export default ldapActions; linagora-ldap-rest-16e557e/src/lib/parseConfig.ts000066400000000000000000000126431522642357000217230ustar00rootroot00000000000000/** * Configuration parser * Order: default < env < cli * @author Xavier Guimard */ import type { Config } from '../config/args'; import type { AttributeValue } from './ldapActions'; export type ConfigTemplate = ConfigEntry[]; type ConfigResultValue = | string | string[] | boolean | number | Record | undefined; export type ConfigEntry = [ string, // arg string, // env value ( // eslint-disable-next-line @typescript-eslint/no-explicit-any string | string[] | boolean | number | Record ), // default value ('string' | 'number' | 'boolean' | 'array' | 'json' | null | undefined)?, // type (string | null | undefined)?, // for array type, the plural form of cliArg (e.g. --plugin / --plugins) ]; export class ConfigParser { private config: ConfigTemplate; constructor(config: ConfigTemplate) { this.config = config; } parse(argv: string[] = process.argv): Config { // @ts-expect-error: missing port const result: Config = {}; const cliArgs = this.parseCliArgs(argv); for (const entry of this.config) { const key = this.getKeyFromCliArg(entry[0]); let value: ConfigResultValue = entry[2]; // Override with env value if exists if (entry[1] !== undefined) { const envValue = process.env[entry[1]]; if (envValue !== undefined) { if (entry[3] === 'boolean') { value = envValue.toLowerCase() === 'true'; } else if (entry[3] === 'number') { value = parseInt(envValue); } else if (entry[3] === 'array') { const sep = envValue.indexOf(';') > 0 ? ';' : ','; value = envValue .split(new RegExp(`[${sep}\\s]+`)) .map(v => v.trim()) .filter(v => v.length > 0); } else if (entry[3] === 'json') { try { value = JSON.parse(envValue) as Record; } catch (e) { throw new Error( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Error parsing JSON from environment variable ${entry[1]}: ${e}` ); } } else { value = envValue; } } } // Override with CLI arg if exists if (cliArgs.has(entry[0])) { const cliValue = cliArgs.get(entry[0]); if (entry[3] === 'boolean') { value = true; } else if (entry[3] === 'number') { value = parseInt(cliValue as string); } else if (entry[3] === 'array') { if (Array.isArray(value)) { value = value.concat(cliValue as string[]); } else { value = cliValue as string[]; } } else if (entry[3] === 'json') { try { value = JSON.parse(cliValue as string) as Record< string, AttributeValue >; } catch (e) { throw new Error( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Error parsing JSON from command line argument ${entry[0]}: ${e}` ); } } else { value = cliValue as string; } cliArgs.delete(entry[0]); } if (entry[3] === 'array' && entry[4] && cliArgs.has(entry[4])) { const cliValue = cliArgs.get(entry[4]) || ''; if (Array.isArray(value)) { value = value.concat( (cliValue as string).split(/[,\s]+/).filter(v => v.length > 0) ); } else { value = (cliValue as string) .split(/[,\s]+/) .filter(v => v.length > 0); } cliArgs.delete(entry[4]); } result[key as keyof Config] = value as never; } // Store additional arguments cliArgs.forEach((v, k) => { if (result[k]) { throw new Error(`Error in command line: ${k} redefined`); } result[k.replace(/^-+/, '').replace(/-/g, '_')] = v; }); return result; } // Command-line parser private parseCliArgs(argv: string[]): Map { const args = new Map(); for (let i = 2; i < argv.length; i++) { const arg = argv[i]; if (arg.startsWith('--') || arg.startsWith('-')) { const configEntry = this.config.find(entry => entry[0] === arg); if (configEntry && configEntry[3] === 'boolean') { args.set(arg, true); } else if (configEntry && configEntry[3] === 'number') { args.set(arg, parseInt(argv[i + 1])); } else if (configEntry && configEntry[3] === 'array') { const tmp = args.get(arg) || []; const nextArg = argv[i + 1]; (tmp as string[]).push(nextArg); args.set(arg, tmp); } else { const nextArg = argv[i + 1]; args.set(arg, nextArg); i++; // Skip la valeur qu'on vient de traiter } } } return args; } private getKeyFromCliArg(cliArg: string): string { if (cliArg.startsWith('--')) { return cliArg.substring(2).replace(/-/g, '_'); } else if (cliArg.startsWith('-')) { return cliArg.substring(1).replace(/-/g, '_'); } return cliArg; } } export function parseConfig( config: ConfigEntry[], argv: string[] = process.argv ): Config { const parser = new ConfigParser(config); return parser.parse(argv); } linagora-ldap-rest-16e557e/src/lib/utils.ts000066400000000000000000000345011522642357000206200ustar00rootroot00000000000000/** * Utility functions * @author Xavier Guimard */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-function-type */ import type { Request, Response, NextFunction, RequestHandler } from 'express'; import type { Config } from '../bin'; import { getLogger } from './expressFormatedResponses'; const logger = getLogger(); // Regex caching utilities - shared across plugins to avoid duplication // NOTE: This cache is designed for static patterns from schemas, NOT for user input. // Using dynamic user-generated patterns would cause unbounded memory growth. // Current usage is limited to schema validation patterns which are finite. const regexCache = new Map(); /** * Get a compiled RegExp from cache, or compile and cache it * This avoids recompiling the same regex patterns repeatedly * * @param pattern - The regex pattern string * @param flags - Optional regex flags * @returns The compiled RegExp */ export function getCompiledRegex(pattern: string, flags?: string): RegExp { const key = flags ? `${pattern}:${flags}` : pattern; let regex = regexCache.get(key); if (!regex) { regex = new RegExp(pattern, flags); regexCache.set(key, regex); } return regex; } /** * Escape special regex characters in a string * Useful when building dynamic regex patterns from user input * * @param str - The string to escape * @returns The escaped string safe for use in RegExp */ export function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // launchHooks launches hooks asynchroniously, errors are reported and ignored. // // Calling convention: VARIADIC. The trailing args are spread into each hook: // launchHooks(hooks, a, b) → hook(a, b) // // Do NOT pre-pack arguments into an array — `launchHooks(hooks, [a, b])` // becomes `hook([a, b])` and any hook reading `a.foo` silently no-ops. // (See PR #66 for the SCIM bug this caused.) // // Mirror the hook's declared signature: // VoidHook<[A, B]> (variadic) → launchHooks(hooks, a, b) // (args: [A, B]) => void (packed-tuple) → launchHooks(hooks, [a, b]) // LDAP "*done" hooks still use the packed-tuple form; SCIM "*done" hooks use // VoidHook. Check src/hooks.ts before adding a new call site. export const launchHooks = async ( hooks: Function[] | undefined, ...args: unknown[] ): Promise => { if (hooks) { for (const hook of hooks) { if (hook) { try { await hook(...args); } catch (e: unknown) { logger.error('Hook error', e); } } } } }; // launchHooksChained threads a single value through each hook, collecting the // returned (possibly modified) value. Any error stops the chain. // // Calling convention: SINGLE PACKED ARG. If a chained hook needs several // inputs, pack them in a tuple — that is what `ChainedHook<[A, B]>` declares. // Unlike launchHooks, the arg is NOT spread; passing `[a, b]` is correct here. export const launchHooksChained = async ( hooks: Function[] | undefined, args: T ): Promise => { if (hooks) { for (const hook of hooks) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment if (hook) args = await hook(args); } } return args; }; export const transformSchemas = ( schemas: string | Buffer, config: Config ): string => { const str = schemas.toString().replace(/__(\S+)__/g, (_, prm) => { if (!prm || typeof prm !== 'string') return _; const key: string = prm.trim().toLowerCase(); if (config[key]) { if (typeof config[key] !== 'object') return config[key] as string; return JSON.stringify(config[key]); } return _; }); return str; }; // LDAP utilities /** * Escape special characters in LDAP filter values according to RFC 4515 * Prevents LDAP injection attacks by escaping characters that have special meaning in LDAP filters * * @param value - The value to escape * @returns The escaped value safe for use in LDAP filters * * @example * ```typescript * escapeLdapFilter('user*') * // => 'user\\2a' * * escapeLdapFilter('Smith, John (admin)') * // => 'Smith, John \\28admin\\29' * ``` */ export function escapeLdapFilter(value: string): string { return value .replace(/\\/g, '\\5c') // backslash .replace(/\*/g, '\\2a') // asterisk .replace(/\(/g, '\\28') // left paren .replace(/\)/g, '\\29') // right paren .replace(/\0/g, '\\00'); // null } /** * Escape special characters in LDAP DN attribute values according to RFC 4514 * Prevents LDAP injection attacks by escaping characters that have special meaning in DNs * * @param value - The value to escape * @returns The escaped value safe for use in LDAP DN attribute values * * @example * ```typescript * escapeDnValue('Smith, John') * // => 'Smith\\, John' * * escapeDnValue('user+admin') * // => 'user\\+admin' * ``` */ export function escapeDnValue(value: string): string { return value .replace(/\\/g, '\\\\') // backslash (must be first) .replace(/,/g, '\\,') // comma .replace(/\+/g, '\\+') // plus .replace(/"/g, '\\"') // double quote .replace(//g, '\\>') // greater than .replace(/;/g, '\\;') // semicolon .replace(/=/g, '\\=') // equals (in value only) .replace(/\0/g, '\\00') // null .replace(/^\s/, '\\ ') // leading space .replace(/\s$/, '\\ ') // trailing space .replace(/^#/, '\\#'); // leading hash } /** * Unescape special characters in LDAP DN attribute values according to RFC 4514 * Reverses the escaping done by escapeDnValue * * @param value - The escaped value to unescape * @returns The unescaped original value * * @example * ```typescript * unescapeDnValue('Smith\\, John') * // => 'Smith, John' * * unescapeDnValue('user\\+admin') * // => 'user+admin' * ``` */ export function unescapeDnValue(value: string): string { let result = ''; let i = 0; while (i < value.length) { if (value[i] === '\\' && i + 1 < value.length) { const next = value[i + 1]; // Handle hex-encoded characters (e.g., \00 for null, \5c for backslash) if (/[0-9a-fA-F]/.test(next) && i + 2 < value.length) { const hex = value.substring(i + 1, i + 3); if (/^[0-9a-fA-F]{2}$/.test(hex)) { result += String.fromCharCode(parseInt(hex, 16)); i += 3; continue; } } // Handle standard escaped characters result += next; i += 2; } else { result += value[i]; i++; } } return result; } /** * Validate a value intended for use in LDAP DN attribute values * Rejects control characters and other problematic inputs * * @param value - The value to validate * @param fieldName - Name of the field (for error messages) * @throws Error if the value contains invalid characters * * @example * ```typescript * validateDnValue('valid-user', 'uid'); * // => OK * * validateDnValue('user\x00name', 'uid'); * // => throws Error: uid contains invalid control characters * ``` */ export function validateDnValue(value: string, fieldName: string): void { if (value == null || typeof value !== 'string') { throw new Error(`${fieldName} must be a string`); } if (value.trim().length === 0) { throw new Error(`${fieldName} must be a non-empty string`); } // Reject control characters (0x00-0x1F and 0x7F) // These are invisible and can cause issues in logs, LDIF exports, etc. if (/[\x00-\x1F\x7F]/.test(value)) { throw new Error(`${fieldName} contains invalid control characters`); } // Reject zero-width and other invisible Unicode characters // U+200B (zero-width space), U+200C-U+200F, U+FEFF (BOM) if (/[\u200B-\u200F\uFEFF]/.test(value)) { throw new Error(`${fieldName} contains invalid invisible characters`); } } // LDAP DN utilities /** * Parse a Distinguished Name (DN) into its component parts (RDNs) * Handles escaped commas and other special characters * * @param dn - The DN to parse * @returns Array of RDN components * * @example * ```typescript * parseDn('uid=user,ou=users,dc=example,dc=com') * // => ['uid=user', 'ou=users', 'dc=example', 'dc=com'] * * parseDn('cn=Smith\\, John,ou=users,dc=example,dc=com') * // => ['cn=Smith\\, John', 'ou=users', 'dc=example', 'dc=com'] * ``` */ export function parseDn(dn: string): string[] { const parts: string[] = []; let current = ''; let escaped = false; for (let i = 0; i < dn.length; i++) { const char = dn[i]; if (escaped) { current += char; escaped = false; } else if (char === '\\') { current += char; escaped = true; } else if (char === ',') { parts.push(current.trim()); current = ''; } else { current += char; } } // Don't forget the last part if (current) { parts.push(current.trim()); } return parts; } /** * Extract the parent DN from a DN * Removes the first RDN component to get the parent branch * * @param dn - The DN to extract parent from * @returns The parent DN, or the original DN if it has no parent * * @example * ```typescript * getParentDn('uid=user,ou=users,dc=example,dc=com') * // => 'ou=users,dc=example,dc=com' * * getParentDn('dc=com') * // => 'dc=com' * ``` */ export function getParentDn(dn: string): string { const parts = parseDn(dn); if (parts.length <= 1) { return dn; } return parts.slice(1).join(','); } /** * Extract the RDN (Relative Distinguished Name) from a DN * Returns the first component of the DN * * @param dn - The DN to extract RDN from * @returns The RDN component * * @example * ```typescript * getRdn('uid=user,ou=users,dc=example,dc=com') * // => 'uid=user' * ``` */ export function getRdn(dn: string): string { const parts = parseDn(dn); return parts[0] || ''; } /** * Check if a DN is a child of another DN * * @param dn - The DN to check * @param parentDn - The potential parent DN * @returns True if dn is a child of parentDn * * @example * ```typescript * isChildOf('uid=user,ou=users,dc=example,dc=com', 'ou=users,dc=example,dc=com') * // => true * * isChildOf('uid=user,ou=users,dc=example,dc=com', 'ou=groups,dc=example,dc=com') * // => false * ``` */ export function isChildOf(dn: string, parentDn: string): boolean { const dnLower = dn.toLowerCase(); const parentLower = parentDn.toLowerCase(); // DN must end with parent DN if (!dnLower.endsWith(parentLower)) { return false; } // DN must be longer than parent (it's a child, not the same) if (dnLower.length === parentLower.length) { return false; } // Check that there's a comma separator before the parent DN part const beforeParent = dnLower.substring( 0, dnLower.length - parentLower.length ); return beforeParent.endsWith(','); } /** * Normalize a DN into a canonical, comparable form. * * Parses the DN into RDNs (honouring escaped commas via {@link parseDn}), then * for each RDN lowercases it, collapses the optional whitespace around the `=` * of every attribute-value assertion, and sorts the assertions of a * multi-valued RDN so their ordering is irrelevant. The RDNs are re-joined with * `,`. Empty components (e.g. from a trailing separator) are dropped. * * This makes equality / suffix comparisons robust to case, surrounding * whitespace and multi-valued RDN ordering — unlike a raw string match. * * @param dn - The DN to normalize * @returns The canonical form * * @example * ```typescript * normalizeDn('UID=x, ou=AppAccounts ,dc=Example,dc=com') * // => 'uid=x,ou=appaccounts,dc=example,dc=com' * ``` */ export function normalizeDn(dn: string): string { return parseDn(dn) .map(rdn => rdn .split('+') .map(atav => { // Split on the first `=` (the type/value separator — attribute types // never contain `=`, and value `=` are escaped as `\=`) and trim each // side. Done with indexOf rather than a `\s*=\s*` regex to avoid the // polynomial scan a regex incurs on untrusted DN strings (ReDoS). const eq = atav.indexOf('='); const normalized = eq === -1 ? atav.trim() : `${atav.slice(0, eq).trim()}=${atav.slice(eq + 1).trim()}`; return normalized.toLowerCase(); }) .sort() .join('+') ) .filter(rdn => rdn.length > 0) .join(','); } /** * Check whether a DN is a branch base itself or sits anywhere below it. * * Comparison is done RDN by RDN on the {@link normalizeDn} form, so it is * robust to case, whitespace and escaped separators. A naive string suffix * match can give a false negative (e.g. `ou=AppAccounts` vs `ou=appaccounts`, * or `uid=x, ou=…` with stray whitespace), which is exactly what lets a * re-entrant LDAP change event slip through a branch guard. * * Unlike {@link isChildOf}, this returns true when `dn` equals `base` as well. * * @param dn - The candidate DN * @param base - The branch base DN * @returns true if `dn` equals `base` or is a descendant of it * * @example * ```typescript * isDnInBranch('uid=x,ou=app,dc=e,dc=c', 'ou=app,dc=e,dc=c') // => true * isDnInBranch('ou=app,dc=e,dc=c', 'ou=app,dc=e,dc=c') // => true * isDnInBranch('uid=x,ou=users,dc=e,dc=c', 'ou=app,dc=e,dc=c') // => false * ``` */ export function isDnInBranch(dn: string, base: string): boolean { const baseParts = normalizeDn(base).split(',').filter(Boolean); if (baseParts.length === 0) return false; const dnParts = normalizeDn(dn).split(',').filter(Boolean); if (dnParts.length < baseParts.length) return false; const tail = dnParts.slice(dnParts.length - baseParts.length); return tail.every((part, i) => part === baseParts[i]); } /** * Wrapper for async Express route handlers to catch errors and pass them to error middleware * This ensures that errors in async routes are properly handled and don't crash the server * * @param fn - The async route handler function * @returns A wrapped handler that catches errors * * @example * ```typescript * app.get('/api/data', asyncHandler(async (req, res) => { * const data = await fetchData(); * res.json(data); * })); * ``` */ export const asyncHandler = ( fn: (req: Request, res: Response, next: NextFunction) => Promise ): RequestHandler => { return (req: Request, res: Response, next: NextFunction) => { Promise.resolve(fn(req, res, next)).catch(next); }; }; linagora-ldap-rest-16e557e/src/logger/000077500000000000000000000000001522642357000176165ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/logger/winston.ts000066400000000000000000000023201522642357000216640ustar00rootroot00000000000000import winston from 'winston'; import { Config } from '../bin'; // Define custom log levels with notice between info and warn (like syslog) const customLevels = { levels: { error: 0, warn: 1, notice: 2, info: 3, debug: 4, }, colors: { error: 'red', warn: 'yellow', notice: 'cyan', info: 'green', debug: 'blue', }, }; // Add notice method to winston.Logger type declare module 'winston' { interface Logger { notice: winston.LeveledLogMethod; } } export const buildLogger = (config: Config): winston.Logger => { return winston.createLogger({ levels: customLevels.levels, level: config.log_level, format: winston.format.combine( winston.format.timestamp(), winston.format.printf(({ timestamp, level, message }) => { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions return `${timestamp} [${level}]: ${message}`; }) ), transports: [ config.logger === 'console' ? new winston.transports.Console({ stderrLevels: ['error', 'warn'], format: winston.format.json(), }) : new winston.transports.File({ filename: 'error.log' }), ], }); }; linagora-ldap-rest-16e557e/src/plugins/000077500000000000000000000000001522642357000200205ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/plugins/README.md000066400000000000000000000062411522642357000213020ustar00rootroot00000000000000# Writing a Plugin This guide explains how to create a plugin using the API and hooks. ## Getting Started - Core-plugins: create a new file in the `plugins` directory. - Other: create your file where you want ## Plugin Structure See [abstract class](../abstract/plugin.ts) for more - A plugin is a class with optional **api** methods and/or **hooks** property. - It should also have a uniq property "**name**" _(which can be overridden, example: `npx ldap-rest --plugin core/hello:myhello`)_. - It should use the `this.config.api_prefix` else it can't be loaded more than one time. - It may inherit from [plugin abstract class](../abstract/plugin.ts). - Its constructor receives one argument _(the server)_ which exposes - hooks: an object `{ [K in keyof Hooks]?: Function[] }` where plugin can find functions to launch if it exposes hooks - ldap: a [LDAP object](../lib/ldapActions.ts) - config: the [config](../config/args.ts) given by command-line arguments and environment variables. Note that this config maybe a modified config. Example: `npx ldap-rest --plugin core/hello:myhello:{"api_prefix":"/myapi"}` - It may have a **dependencies** property: _`Record`_ of plugins that needs to be loaded before this one. ### Examples to expose an API _(or any [express](https://www.npmjs.com/package/express) hook)_ #### Simple ```typescript import type { Express, Request, Response } from 'express'; import DmPlugin from '../abstract/plugin'; export default class HelloWorld extends DmPlugin { name = 'hello'; api(app: Express): void { app.get('/hello', (req: Request, res: Response) => { res.json({ message: 'Hello' }); }); } } ``` #### Launch hooks exported by other plugins Note that exposed hooks has to be documented into [HOOKS](../../HOOKS.md) ```typescript import type { Express, Request, Response } from 'express'; import DmPlugin from '../abstract/plugin'; export default class HelloWorld extends DmPlugin { name = 'hello'; api(app: Express): void { app.get('/hello', (req: Request, res: Response) => { const response = { message: 'Hello', hookResults: [] as unknown[] }; if (this.server.hooks && this.server.hooks['hello']) { for (const hook of this.server.hooks['hello']) { // eslint-disable-next-line @typescript-eslint/no-unsafe-call response.hookResults.push(hook()); } } res.json(response); }); } } ``` ### Export hooks Hooks are JS objects that associate a keyword to a function. This function will be launched by the plugin which exposes this hook. In the following example, we know that the [helloworld plugin](../plugins/helloworld.ts) consumes hooks named 'hello' and want functions that return strings and receive no parameters. ```typescript import type { Express, Request, Response } from 'express'; import type { SearchOptions } from 'ldapts'; import type { Hooks } from '../hooks.ts'; // also exported by this module, see package.json import DmPlugin from '../abstract/plugin'; export default class HelloWorld extends DmPlugin { name = 'hello'; hooks: Hooks = { ldapsearchopts: opts => { // change options return opts; }, }; } ``` linagora-ldap-rest-16e557e/src/plugins/auth/000077500000000000000000000000001522642357000207615ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/plugins/auth/authzDynamic.ts000066400000000000000000000432471522642357000240030ustar00rootroot00000000000000/** * @module plugins/auth/authzDynamic * @author Xavier Guimard * * LDAP-backed authentication + per-branch authorization plugin. * * Tokens are stored as entries under a dedicated LDAP branch (one entry per * bearer token). The plugin caches them and enforces read / write / delete * permissions on subsequent LDAP operations — so each client is scoped to * its own subtree without touching the process configuration. * * Entry shape (conventions, overridable via CLI): * dn: cn=, * cn: * userPassword: {SSHA}… ← secret, hashed * description: {"tenant":"acme", ← JSON config * "bases":[ * {"dn":"ou=users,ou=acme,dc=example,dc=com", * "read":true,"write":true,"delete":true} * ]} * * The entry is treated as "token for tenant `acme`, permitted to read/write/ * delete under `ou=users,ou=acme,…`". Multiple bases per token are allowed. * Sub-branch matching is used (a permission on `ou=acme` covers * `ou=users,ou=acme`). */ import { AsyncLocalStorage } from 'async_hooks'; import type { Express, Response } from 'express'; import type { SearchOptions } from 'ldapts'; import { type Role } from '../../abstract/plugin'; import type { DM } from '../../bin'; import AuthBase, { type DmRequest } from '../../lib/auth/base'; import { ForbiddenError } from '../../lib/errors'; import type { AttributesList, AttributeValue, ModifyRequest, SearchResult, } from '../../lib/ldapActions'; import type { Hooks } from '../../hooks'; import { ok, unauthorized } from '../../lib/expressFormatedResponses'; import { asyncHandler, getParentDn } from '../../lib/utils'; import { verifyLdapPassword } from './authzDynamicHash'; export interface BranchAcl { dn: string; read?: boolean; write?: boolean; delete?: boolean; } export interface TokenConfig { tenant?: string; bases?: BranchAcl[]; } export interface TokenEntry { dn: string; cn: string; tenant: string; hash: string; bases: BranchAcl[]; } /** Extends DmRequest with the resolved token entry for downstream hooks. */ export interface AuthzDynamicRequest extends DmRequest { authzToken?: TokenEntry; } /** * Per-request async context holding the authenticated token. * * Core plugins (ldapGroups, ldapFlat, etc.) do not always forward the Express * `req` object down to `ldapActions.search()` / `.modify()`. Relying solely * on `req` would therefore let un-tracked operations bypass authorization. * Using AsyncLocalStorage gives us a request-scoped, ambient handle that the * authz hooks can read regardless of whether the caller threaded `req`. */ export const authzContext = new AsyncLocalStorage(); function asString(v: AttributeValue | undefined): string | undefined { if (v == null) return undefined; if (Array.isArray(v)) { const first = v[0]; return first == null ? undefined : Buffer.isBuffer(first) ? first.toString() : String(first); } if (Buffer.isBuffer(v)) return v.toString(); return String(v); } /** Case-insensitive sub-branch membership: `dn` is at or under `branch`. */ function isAtOrUnder(dn: string, branch: string): boolean { const a = dn.toLowerCase().trim(); const b = branch.toLowerCase().trim(); if (!a || !b) return false; if (a === b) return true; return a.endsWith(',' + b); } export default class AuthzDynamic extends AuthBase { name = 'authzDynamic'; roles: Role[] = ['auth', 'authz', 'configurable'] as const; private tokens: TokenEntry[] = []; private lastLoad = 0; private lastFailure = 0; private readonly cacheTtlMs: number; private readonly failureBackoffMs: number; private readonly base: string; private readonly tokenAttr: string; private readonly configAttr: string; private readonly tenantAttr: string; private readonly reloadEndpoint: boolean; private loading?: Promise; constructor(server: DM) { super(server); this.base = (this.config.authz_dynamic_base as string) || ''; this.cacheTtlMs = ((this.config.authz_dynamic_cache_ttl as number) || 60) * 1000; // Backoff window after a failed reload, so that LDAP outages don't turn // into a per-request retry storm. Defaults to max(1/4 × TTL, 5s). this.failureBackoffMs = Math.max(Math.floor(this.cacheTtlMs / 4), 5000); this.tokenAttr = (this.config.authz_dynamic_token_attribute as string) || 'userPassword'; this.configAttr = (this.config.authz_dynamic_config_attribute as string) || 'description'; this.tenantAttr = (this.config.authz_dynamic_tenant_attribute as string) || 'cn'; this.reloadEndpoint = Boolean(this.config.authz_dynamic_reload_endpoint); if (!this.base) { throw new Error( 'authzDynamic requires --authz-dynamic-base (the LDAP branch holding token entries)' ); } } api(app: Express): void { // Register the AuthBase middleware (runs authMethod per request). super.api(app); // The `serverError()` helper and DM's core error middleware both check // for the `[authz-forbidden]` marker (added to ForbiddenError messages // thrown by our hooks). Downstream plugins that wrap LDAP errors into // plain `new Error(...)` drop the original `statusCode`; the marker // preserves the semantic 403 across those wraps. if (this.reloadEndpoint) { const prefix = this.config.api_prefix || '/api'; /** * @openapi * summary: Reload the authz-dynamic token cache * description: | * Forces an immediate reload of all token entries from the LDAP * branch configured via `--authz-dynamic-base`. * * **Conditional registration:** this endpoint is only added to the * router when the server was started with * `--authz-dynamic-reload-endpoint true`. It will be absent * (404) otherwise. * * **Authentication required:** the request must carry a valid * `Authorization: Bearer ` header that matches an existing * entry in the token cache. An unauthenticated or unrecognised * token receives a `401` response. * * On success the response includes the number of tokens now held * in the in-memory cache. This count can be used to verify that * the expected entries are present after adding or removing tokens * in LDAP. * security: * - BearerAuth: [] * responses: * '200': * description: Cache reloaded successfully. * content: * application/json: * schema: * type: object * required: [success, tokens] * properties: * success: * type: boolean * example: true * tokens: * type: integer * description: Number of token entries now in the cache. * example: 3 * example: * success: true * tokens: 3 * '401': * description: Missing, invalid, or unrecognised bearer token. */ app.post( `${prefix}/v1/authz-dynamic/reload`, asyncHandler(async (req, res) => { // Only an already-authenticated token may trigger reload. const typed = req as AuthzDynamicRequest; if (!typed.authzToken) { unauthorized(res); return; } await this.reload(); ok(res, { success: true, tokens: this.tokens.length }); }) ); this.logger.info( `authzDynamic reload endpoint registered at ${prefix}/v1/authz-dynamic/reload` ); } } /** * Load token entries from LDAP. Swallows errors so a transient LDAP hiccup * does not poison the cache — the previous snapshot stays in use. */ async reload(): Promise { try { const attrs = Array.from( new Set(['dn', 'cn', this.tokenAttr, this.configAttr, this.tenantAttr]) ); const result = (await this.server.ldap.search( { filter: '(objectClass=*)', scope: 'sub', paged: false, attributes: attrs, }, this.base )) as SearchResult; const next: TokenEntry[] = []; for (const entry of result.searchEntries || []) { const dn = typeof entry.dn === 'string' ? entry.dn : String(entry.dn); const cn = asString(entry.cn as AttributeValue) || ''; const hash = asString(entry[this.tokenAttr] as AttributeValue) || ''; if (!hash) continue; // skip container OUs / entries without secret // Tenant: prefer the `tenant` field in the JSON config, fall back to // the configured tenant attribute (default `cn`). let tenantFromJson: string | undefined; let bases: BranchAcl[] = []; const cfgRaw = asString(entry[this.configAttr] as AttributeValue); if (cfgRaw) { try { const parsed = JSON.parse(cfgRaw) as TokenConfig; if (parsed && typeof parsed.tenant === 'string' && parsed.tenant) { tenantFromJson = parsed.tenant; } if (parsed && Array.isArray(parsed.bases)) { bases = parsed.bases .filter(b => b && typeof b.dn === 'string' && b.dn.length > 0) .map(b => ({ dn: b.dn, read: Boolean(b.read), write: Boolean(b.write), delete: Boolean(b.delete), })); } } catch (err) { this.logger.warn( `authzDynamic: invalid JSON in ${this.configAttr} of ${dn}: ${String(err)}` ); } } const tenantFromAttr = this.tenantAttr === 'cn' ? cn : asString(entry[this.tenantAttr] as AttributeValue) || cn; const tenant = tenantFromJson || tenantFromAttr; next.push({ dn, cn, tenant, hash, bases }); } this.tokens = next; this.lastLoad = Date.now(); this.lastFailure = 0; this.logger.info( `authzDynamic: loaded ${next.length} token(s) from ${this.base}` ); } catch (err) { this.lastFailure = Date.now(); this.logger.error( `authzDynamic: failed to load tokens from ${this.base}: ${String(err)}` ); } } /** * Ensure the cache is fresh (blocks if reloading). * * On a bootstrap or successful reload the cache is valid for * `cacheTtlMs`. If the load fails, we still refuse to retry on every * request — `failureBackoffMs` (default 1/4 of TTL, min 5 s) keeps the * previous snapshot in use and shields LDAP from a thundering-herd * during an outage. */ private async ensureFresh(): Promise { const now = Date.now(); if (this.lastLoad > 0 && now - this.lastLoad < this.cacheTtlMs) return; // Under a current failure window, stick with the previous snapshot. if ( this.lastFailure > 0 && now - this.lastFailure < this.failureBackoffMs ) { return; } if (this.loading) return this.loading; this.loading = this.reload().finally(() => { this.loading = undefined; }); return this.loading; } authMethod(req: DmRequest, res: Response, next: () => void): void { // If an earlier auth plugin already identified the caller, treat that as // authoritative: we only enrich the request with a token/ACL if we can // find a matching entry, but we do not refuse a request that already // carries a `req.user` set by another middleware. This keeps the plugin // composable (e.g. trustedProxy + authzDynamic for admin bypass). if (req.user) { next(); return; } void this.ensureFresh() .then(() => { const header = req.headers['authorization']; if (!header || !/^Bearer\s+/.test(header)) { this.logger.warn( 'authzDynamic: missing or invalid Authorization header' ); unauthorized(res); return; } const token = header.slice(header.indexOf(' ') + 1).trim(); if (!token) { unauthorized(res); return; } // Linear scan — number of tokens is expected to be small (tens / low // hundreds). If scaling is ever needed, index by hash scheme buckets. let match: TokenEntry | undefined; for (const entry of this.tokens) { if (verifyLdapPassword(token, entry.hash)) { match = entry; break; } } if (!match) { const masked = token.length > 8 ? `${token.substring(0, 8)}...` : '***'; this.logger.warn(`authzDynamic: unauthorized token ${masked}`); unauthorized(res); return; } req.user = match.tenant; (req as AuthzDynamicRequest).authzToken = match; // Run `next` (and everything downstream) inside an AsyncLocalStorage // frame so authz hooks can read the token even when plugins don't // thread `req` into ldapActions calls. authzContext.run(match, next); }) .catch(err => { this.logger.error(`authzDynamic: authMethod error: ${String(err)}`); unauthorized(res); }); } /** * Authorization hooks. These run after the auth middleware has populated * `req.authzToken`, so we can read the allowed bases directly from the * request. */ hooks: Hooks = { ldapsearchrequest: ([base, opts, req]: [ string, SearchOptions, DmRequest?, ]): [string, SearchOptions, DmRequest?] => { const token = this.activeToken(req); if (!token) return [base, opts, req]; this.requireBranchPermission(token, base, 'read'); return [base, opts, req]; }, ldapaddrequest: ([dn, entry, req]: [string, AttributesList, DmRequest?]): [ string, AttributesList, DmRequest?, ] => { const token = this.activeToken(req); if (!token) return [dn, entry, req]; this.requireBranchPermission(token, getParentDn(dn), 'write'); return [dn, entry, req]; }, ldapmodifyrequest: ([dn, changes, opNumber, req]: [ string, ModifyRequest, number, DmRequest?, ]): [string, ModifyRequest, number, DmRequest?] => { const token = this.activeToken(req); if (!token) return [dn, changes, opNumber, req]; this.requireBranchPermission(token, getParentDn(dn), 'write'); return [dn, changes, opNumber, req]; }, // Prefer the request-borne token; fall back to AsyncLocalStorage's active // token for callers that do not thread `req`. Enforce delete permission on // every target DN's parent branch. ldapdeleterequest: ([dn, req]: [string | string[], DmRequest?]): [ string | string[], DmRequest?, ] => { const token = this.activeToken(req); if (!token) return [dn, req]; const list = Array.isArray(dn) ? dn : [dn]; for (const target of list) { this.requireBranchPermission(token, getParentDn(target), 'delete'); } return [dn, req]; }, ldaprenamerequest: ([oldDn, newDn, req]: [string, string, DmRequest?]): [ string, string, DmRequest?, ] => { const token = this.activeToken(req); if (!token) return [oldDn, newDn, req]; this.requireBranchPermission(token, getParentDn(oldDn), 'read'); this.requireBranchPermission(token, getParentDn(newDn), 'write'); return [oldDn, newDn, req]; }, }; /** Read the active token from the async context, or (as a fallback) from `req`. */ private activeToken(req?: DmRequest): TokenEntry | undefined { return ( authzContext.getStore() || (req as AuthzDynamicRequest | undefined)?.authzToken ); } /** * Throw a ForbiddenError (403) if the token cannot perform `permission` on * the branch containing `branchOrDn`. Any ACL entry that is `branchOrDn` * itself or a parent of it (sub-branch match) qualifies. * * The client-facing message omits the target DN to avoid leaking tenant * topology through authorization failures; the full context is still * logged server-side at debug level for operators. */ private requireBranchPermission( token: TokenEntry, branchOrDn: string, permission: 'read' | 'write' | 'delete' ): void { for (const acl of token.bases) { if (!isAtOrUnder(branchOrDn, acl.dn)) continue; if (acl[permission]) return; } this.logger.debug( `authzDynamic: token "${token.tenant}" denied ${permission} on ${branchOrDn}` ); // A marker in the message lets our Express error middleware recognise // this failure even after intermediate plugins (e.g. ldapGroups) wrap // the error into a plain `new Error(...)` — which would otherwise drop // `statusCode` and surface as a 500. throw new ForbiddenError( `[authz-forbidden] Token does not have ${permission} permission on this branch` ); } /** Expose current cache (primarily for the configApi auto-discovery). */ getConfigApiData(): Record { return { enabled: true, base: this.base, cacheTtlSeconds: this.cacheTtlMs / 1000, tokenCount: this.tokens.length, reloadEndpoint: this.reloadEndpoint ? `${this.config.api_prefix || '/api'}/v1/authz-dynamic/reload` : undefined, tokenAttribute: this.tokenAttr, configAttribute: this.configAttr, tenantAttribute: this.tenantAttr, // Intentionally NOT exposing hashes or actual DNs of tokens // to avoid leaking tenant topology via the config endpoint. }; } // Used internally by tests / eager refresh _tokens(): TokenEntry[] { return this.tokens; } } linagora-ldap-rest-16e557e/src/plugins/auth/authzDynamicHash.ts000066400000000000000000000126511522642357000246020ustar00rootroot00000000000000/** * @module plugins/auth/authzDynamicHash * @author Xavier Guimard * * Verify bearer tokens against LDAP `userPassword` hashes. * * Supported RFC 3112 schemes: * {SSHA} — salted SHA-1 (OpenLDAP default) * {SHA} — unsalted SHA-1 * {SSHA256} — salted SHA-256 * {SHA256} — unsalted SHA-256 * {SSHA512} — salted SHA-512 * {SHA512} — unsalted SHA-512 * {MD5} — unsalted MD5 (legacy, not recommended) * {SMD5} — salted MD5 (legacy, not recommended) * {CLEARTEXT} / no prefix — cleartext (test environments only) * * All comparisons use `crypto.timingSafeEqual` / `timingSafeStringEqual` * to prevent timing side-channels on the secret. */ import crypto from 'crypto'; const HASH_LENGTHS: Record = { sha1: 20, sha256: 32, sha512: 64, md5: 16, }; function timingSafeStringEqual(a: string, b: string): boolean { const bufA = Buffer.from(a, 'utf8'); const bufB = Buffer.from(b, 'utf8'); if (bufA.length !== bufB.length) return false; return crypto.timingSafeEqual(bufA, bufB); } /** * Strict canonical-base64 check. `Buffer.from(x, 'base64')` silently drops * garbage characters, so we must validate before decoding — otherwise a hash * with a smuggled prefix could be accepted with a shorter effective payload. */ function isCanonicalBase64(payload: string): boolean { if (payload.length === 0 || payload.length % 4 !== 0) return false; if (!/^[A-Za-z0-9+/]+={0,2}$/.test(payload)) return false; // Round-trip must reproduce the input byte-for-byte. return Buffer.from(payload, 'base64').toString('base64') === payload; } function verifySalted( algo: 'sha1' | 'sha256' | 'sha512' | 'md5', payload: string, password: string ): boolean { if (!isCanonicalBase64(payload)) return false; const decoded = Buffer.from(payload, 'base64'); const digestLen = HASH_LENGTHS[algo]; if (decoded.length <= digestLen) return false; const storedHash = decoded.subarray(0, digestLen); const salt = decoded.subarray(digestLen); const computed = crypto .createHash(algo) .update(Buffer.from(password, 'utf8')) .update(salt) .digest(); if (computed.length !== storedHash.length) return false; return crypto.timingSafeEqual(storedHash, computed); } function verifyUnsalted( algo: 'sha1' | 'sha256' | 'sha512' | 'md5', payload: string, password: string ): boolean { if (!isCanonicalBase64(payload)) return false; const computed = crypto .createHash(algo) .update(Buffer.from(password, 'utf8')) .digest('base64'); return timingSafeStringEqual(payload, computed); } /** * Verify a provided bearer token against a stored LDAP-style hash. * * The stored hash is typically the contents of `userPassword`: * either `{SCHEME}` or cleartext. */ export function verifyLdapPassword(provided: string, stored: string): boolean { if (typeof provided !== 'string' || provided.length === 0) return false; if (typeof stored !== 'string' || stored.length === 0) return false; const m = /^\{([A-Za-z0-9-]+)\}(.*)$/.exec(stored); if (!m) { // No scheme prefix → treat as cleartext return timingSafeStringEqual(provided, stored); } const scheme = m[1].toUpperCase(); const payload = m[2]; switch (scheme) { case 'SSHA': return verifySalted('sha1', payload, provided); case 'SHA': return verifyUnsalted('sha1', payload, provided); case 'SSHA256': return verifySalted('sha256', payload, provided); case 'SHA256': return verifyUnsalted('sha256', payload, provided); case 'SSHA512': return verifySalted('sha512', payload, provided); case 'SHA512': return verifyUnsalted('sha512', payload, provided); case 'SMD5': return verifySalted('md5', payload, provided); case 'MD5': return verifyUnsalted('md5', payload, provided); case 'CLEARTEXT': case 'PLAIN': return timingSafeStringEqual(provided, payload); default: // Unknown scheme: refuse authentication rather than risk a silent match. return false; } } /** * Convenience helpers for tests / tooling to generate an LDAP-style hash. * * These are NOT used by the runtime verification path and NOT intended for * use as a general password-hashing function: the primary use case is * seeding LDAP token fixtures in tests. Production `userPassword` values * should be generated by `slappasswd` (or another operator tool) with a * scheme of your choice. The RFC 3112 `{SSHA}` scheme uses a single SHA-1 * round — adequate for LDAP bearer-token interop, not adequate for * user-chosen passwords. Prefer `sshaHash('sha512', …)` (`{SSHA512}`) when * generating new fixtures. */ export type SshaAlgo = 'sha1' | 'sha256' | 'sha512'; const SCHEME_BY_ALGO: Record = { sha1: 'SSHA', sha256: 'SSHA256', sha512: 'SSHA512', }; export function sshaHash( algo: SshaAlgo, password: string, salt?: Buffer ): string { const s = salt || crypto.randomBytes(16); const digest = crypto .createHash(algo) .update(Buffer.from(password, 'utf8')) .update(s) .digest(); return `{${SCHEME_BY_ALGO[algo]}}${Buffer.concat([digest, s]).toString('base64')}`; } /** * Legacy alias: generate an {SSHA} (salted SHA-1) hash. * Retained so existing tests keep working. Prefer `sshaHash('sha512', ...)` * for new fixtures. */ export function ssha(password: string, salt?: Buffer): string { return sshaHash('sha1', password, salt); } linagora-ldap-rest-16e557e/src/plugins/auth/authzLinid1.ts000066400000000000000000000127721522642357000235360ustar00rootroot00000000000000/** * @module plugins/auth/authzLinid1 * @author Xavier Guimard * * Authorization plugin based on twakeLocalAdminLink LDAP attribute * Grants permissions to users referenced in organization's twakeLocalAdminLink * @group Plugins */ import type { DM } from '../../bin'; import type { SearchResult } from '../../lib/ldapActions'; import type { BranchPermissions } from '../../config/args'; import AuthzBase from '../../lib/authz/base'; interface CachedPermissions { branches: Map; timestamp: number; } export default class AuthzLinid1 extends AuthzBase { name = 'authzLinid1'; permissionsCache: Map = new Map(); constructor(server: DM) { super(server); // Cache TTL in milliseconds (default: 5 minutes) this.cacheTTL = 5 * 60 * 1000; const adminAttr = this.config.authz_local_admin_attribute || 'twakeLocalAdminLink'; this.logger.info( `AuthzLinid1: Authorization based on ${adminAttr} attribute enabled` ); } /** * Resolve user - for authzLinid1, convert uid to userDn via LDAP */ async resolveUser(uid: string): Promise { return this.getUserDn(uid); } // Note: hooks are inherited from AuthzBase /** * Get user DN from uid */ async getUserDn(uid: string): Promise { try { const filter = `(${this.config.ldap_user_main_attribute || 'uid'}=${uid})`; const result = (await this.server.ldap.search( { paged: false, filter, attributes: ['dn'], scope: 'sub', }, this.config.ldap_base || '' )) as SearchResult; if (result.searchEntries && result.searchEntries.length > 0) { const dn = result.searchEntries[0].dn; return typeof dn === 'string' ? dn : String(dn); } } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions this.logger.error(`Failed to get DN for user ${uid}: ${err}`); } return null; } /** * Get user's permissions for a specific branch * Searches LDAP for organizations with twakeLocalAdminLink containing the user's DN */ async getUserPermissions( userDn: string, branch: string ): Promise { const now = Date.now(); // Check cache const cached = this.permissionsCache.get(userDn); if (cached && now - cached.timestamp < this.cacheTTL) { const branchPerms = this.findBranchPermissions(cached.branches, branch); if (branchPerms) { return branchPerms; } } // Refresh permissions from LDAP await this.refreshUserPermissions(userDn); // Try again from cache const updatedCache = this.permissionsCache.get(userDn); if (updatedCache) { const branchPerms = this.findBranchPermissions( updatedCache.branches, branch ); if (branchPerms) { return branchPerms; } } // No permissions found - return default deny return { read: false, write: false, delete: false }; } /** * Refresh user permissions from LDAP */ async refreshUserPermissions(userDn: string): Promise { const branches = new Map(); try { // Search for all organizations where this user is in local admin attribute const adminAttr = this.config.authz_local_admin_attribute || 'twakeLocalAdminLink'; const filter = `(${adminAttr}=${userDn})`; const orgBase = this.config.ldap_top_organization || this.config.ldap_base || ''; const result = (await this.server.ldap.search( { paged: false, filter, attributes: ['dn'], scope: 'sub', }, orgBase )) as SearchResult; if (result.searchEntries) { for (const entry of result.searchEntries) { const dn = typeof entry.dn === 'string' ? entry.dn : String(entry.dn); // Grant full permissions (read, write, delete) for managed branches branches.set(dn, { read: true, write: true, delete: true }); } } } catch (err) { this.logger.error( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Failed to refresh permissions for user ${userDn}: ${err}` ); } // Update cache this.permissionsCache.set(userDn, { branches, timestamp: Date.now(), }); } /** * Get list of branches user has access to */ async getAuthorizedBranches(userDn: string): Promise { const now = Date.now(); // Check cache const cached = this.permissionsCache.get(userDn); if (!cached || now - cached.timestamp >= this.cacheTTL) { await this.refreshUserPermissions(userDn); } const updatedCache = this.permissionsCache.get(userDn); if (updatedCache) { return Array.from(updatedCache.branches.keys()); } return []; } /** * Find permissions for a branch (supports sub-branch matching) */ private findBranchPermissions( branchPerms: Map, targetBranch: string ): BranchPermissions | null { // Exact match first if (branchPerms.has(targetBranch)) { return branchPerms.get(targetBranch) || null; } // Check if targetBranch is a sub-branch of any configured branch for (const [branch, perms] of branchPerms.entries()) { if (targetBranch.toLowerCase().endsWith(`,${branch.toLowerCase()}`)) { return perms; } } return null; } } linagora-ldap-rest-16e557e/src/plugins/auth/authzPerBranch.ts000066400000000000000000000162601522642357000242560ustar00rootroot00000000000000/** * @module plugins/auth/authzPerBranch * @author Xavier Guimard * * Authorization plugin that restricts LDAP access by branch * Supports user and group-based permissions with configurable defaults * @group Plugins */ import type { DM } from '../../bin'; import type { SearchResult } from '../../lib/ldapActions'; import type { AuthConfig, BranchPermissions } from '../../config/args'; import type { DmRequest } from '../../lib/auth/base'; import AuthzBase from '../../lib/authz/base'; interface CachedGroups { groups: string[]; timestamp: number; } export default class AuthzPerBranch extends AuthzBase { name = 'authzPerBranch'; authConfig?: AuthConfig; groupCache: Map = new Map(); constructor(server: DM) { super(server); // Cache TTL in milliseconds (default: 1 minute, configurable) this.cacheTTL = (this.config.authz_per_branch_cache_ttl || 60) * 1000; // Load authorization config from config object this.authConfig = this.config.authz_per_branch_config; if (this.authConfig) { this.logger.info('Authorization config loaded'); } } /** * Resolve user - for authzPerBranch, we use uid directly */ resolveUser(uid: string): Promise { return Promise.resolve(uid); } /** * Override to add authConfig check */ protected shouldSkipAuthorization(req?: DmRequest): boolean { return !req?.user || !this.authConfig; } // Note: hooks are inherited from AuthzBase /** * Get user's permissions for a specific branch */ async getUserPermissions( uid: string, branch: string ): Promise { if (!this.authConfig) { return { read: true, write: true, delete: true }; } // Start with default permissions let permissions: BranchPermissions = { read: this.authConfig.default?.read ?? false, write: this.authConfig.default?.write ?? false, delete: this.authConfig.default?.delete ?? false, }; // Check user-specific permissions if (this.authConfig.users?.[uid]) { const userPerms = this.findBranchPermissions( this.authConfig.users[uid], branch ); if (userPerms) { permissions = this.mergePermissions(permissions, userPerms); } } // Check group-based permissions const userGroups = await this.getUserGroups(uid); for (const groupDn of userGroups) { if (this.authConfig.groups?.[groupDn]) { const groupPerms = this.findBranchPermissions( this.authConfig.groups[groupDn], branch ); if (groupPerms) { permissions = this.mergePermissions(permissions, groupPerms); } } } return permissions; } /** * Get list of branches user has read access to (base class implementation) */ async getAuthorizedBranches(uid: string): Promise { return this.getAuthorizedBranchesForPermission(uid, 'read'); } /** * Get list of branches user has access to for a given permission type */ async getAuthorizedBranchesForPermission( uid: string, permissionType: 'read' | 'write' | 'delete' ): Promise { if (!this.authConfig) { return []; } const branches: string[] = []; // Check user-specific permissions if (this.authConfig.users?.[uid]) { for (const [branch, perms] of Object.entries( this.authConfig.users[uid] )) { if (perms[permissionType]) { branches.push(branch); } } } // Check group-based permissions const userGroups = await this.getUserGroups(uid); for (const groupDn of userGroups) { if (this.authConfig.groups?.[groupDn]) { for (const [branch, perms] of Object.entries( this.authConfig.groups[groupDn] )) { if (perms[permissionType] && !branches.includes(branch)) { branches.push(branch); } } } } return branches; } /** * Find permissions for a branch (supports sub-branch matching) */ private findBranchPermissions( branchPerms: { [branch: string]: BranchPermissions }, targetBranch: string ): BranchPermissions | null { // Exact match first if (branchPerms[targetBranch]) { return branchPerms[targetBranch]; } // Check if targetBranch is a sub-branch of any configured branch for (const [branch, perms] of Object.entries(branchPerms)) { if (targetBranch.toLowerCase().endsWith(`,${branch.toLowerCase()}`)) { return perms; } } return null; } /** * Merge permissions (OR logic - grant if any source grants) */ private mergePermissions( current: BranchPermissions, additional: BranchPermissions ): BranchPermissions { return { read: current.read || additional.read || false, write: current.write || additional.write || false, delete: current.delete || additional.delete || false, }; } /** * Build LDAP filter for authorized branches */ private buildBranchFilter( baseDn: string, authorizedBranches: string[] ): string | null { // If base DN is within authorized branches, no additional filter needed const baseInAuthorized = authorizedBranches.some( branch => baseDn.toLowerCase() === branch.toLowerCase() || baseDn.toLowerCase().endsWith(`,${branch.toLowerCase()}`) ); if (baseInAuthorized) { return null; } // Otherwise, restrict to authorized branches if (authorizedBranches.length === 1) { return `(entryDN=*,${authorizedBranches[0]})`; } else if (authorizedBranches.length > 1) { const filters = authorizedBranches .map(branch => `(entryDN=*,${branch})`) .join(''); return `(|${filters})`; } return null; } /** * Get user's group memberships with caching */ async getUserGroups(uid: string): Promise { const now = Date.now(); // Check cache const cached = this.groupCache.get(uid); if (cached && now - cached.timestamp < this.cacheTTL) { return cached.groups; } // Resolve groups from LDAP const groups: string[] = []; try { // Search for groups where user is a member, using wildcard pattern // This works regardless of where the user DN is located const memberAttr = this.config.ldap_group_member_attribute || 'member'; const filter = `(${memberAttr as string}=${this.config.ldap_user_main_attribute}=${uid},*)`; const searchResult = (await this.server.ldap.search( { paged: false, filter, attributes: ['dn'], }, this.server.ldap.base )) as SearchResult; if (searchResult.searchEntries) { for (const entry of searchResult.searchEntries) { if (entry.dn) { groups.push( typeof entry.dn === 'string' ? entry.dn : String(entry.dn) ); } } } } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions this.logger.error(`Failed to resolve groups for user ${uid}: ${err}`); } // Update cache this.groupCache.set(uid, { groups, timestamp: now }); return groups; } } linagora-ldap-rest-16e557e/src/plugins/auth/authzPerRoute.ts000066400000000000000000000140551522642357000241570ustar00rootroot00000000000000/** * @module core/auth/authzPerRoute * Route-level authorization plugin. * * Restricts access by HTTP method + path based on the authenticated user name * (req.user, set by an auth plugin loaded before this one). * * @author Xavier Guimard */ import type { Express, Request, Response, NextFunction } from 'express'; import DmPlugin, { type Role } from '../../abstract/plugin'; import { forbidden } from '../../lib/expressFormatedResponses'; import type { DmRequest } from '../../lib/auth/base'; // Whitelist of characters permitted in a glob pattern. // Covers all characters needed for typical REST paths: alphanumerics, slash, // underscore, hyphen, dot, plus, and the glob wildcards (*). // Any pattern containing characters outside this set is rejected. const ALLOWED_GLOB_CHARS = /^[\w/.\-+*]*$/; // Convert a glob pattern to a RegExp. '*' matches one path segment ([^/]*), // '**' matches any sequence including '/' (.*). All other characters are escaped. // // Security: the pattern is validated against ALLOWED_GLOB_CHARS before any // regex construction, providing a whitelist guard recognised by CodeQL's // js/regex-injection query. Only the sanitised, escaped string flows into // new RegExp. // // Throws if the glob contains characters outside the allowed set. export function globToRegex(glob: string): RegExp { if (!ALLOWED_GLOB_CHARS.test(glob)) { throw new Error( `Invalid glob pattern: "${glob}" — only [a-zA-Z0-9_/.\-+*] are allowed` ); } // Escape every regex-significant character first (producing a safe string). // Among the whitelisted chars only `.` and `+` are regex metacharacters; // `*` is also flagged by the broad escape set and will be escaped too. const escaped = glob.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); // After escaping: `**` → `\*\*`, `*` → `\*`. // Restore glob semantics on the already-sanitised string. const pattern = escaped.replace(/\\\*\\\*/g, '.*').replace(/\\\*/g, '[^/]*'); return new RegExp(`^${pattern}$`); } interface WildcardRule { kind: 'wildcard'; } interface MethodPathRule { kind: 'method-path'; method: string; // uppercase HTTP verb or '*' pathRe: RegExp; } type AuthzRule = WildcardRule | MethodPathRule; export default class AuthzPerRoute extends DmPlugin { name = 'authzPerRoute'; roles: Role[] = ['authz'] as const; private rules: Map = new Map(); constructor(...args: ConstructorParameters) { super(...args); const entries = (this.config.authz_per_route as string[] | undefined) ?? []; for (const entry of entries) { this.parseEntry(entry); } const summary = [...this.rules.entries()] .map(([user, rules]) => { const hasWildcard = rules.some(r => r.kind === 'wildcard'); return `${user}: ${hasWildcard ? 'full access' : `${rules.length} rule${rules.length !== 1 ? 's' : ''}`}`; }) .join(', '); this.logger.info( `authzPerRoute: ${this.rules.size} user${this.rules.size !== 1 ? 's' : ''} configured${this.rules.size > 0 ? ` (${summary})` : ''}` ); } private parseEntry(entry: string): void { const parts = entry.split(':').map(p => p.trim()); if (parts.length < 2) { this.logger.warn(`authzPerRoute: ignoring invalid rule entry: ${entry}`); return; } const user = parts[0]; if (!user) { this.logger.warn( `authzPerRoute: ignoring rule with empty user: ${entry}` ); return; } // ":*" — full wildcard if (parts.length === 2 && parts[1] === '*') { this.addRule(user, { kind: 'wildcard' }); return; } // "::" if (parts.length >= 3) { const method = parts[1].toUpperCase(); const VALID_METHODS = new Set([ 'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', '*', ]); if (!method || !VALID_METHODS.has(method)) { this.logger.warn( `authzPerRoute: ignoring rule with invalid method "${parts[1]}" in entry: ${entry}` ); return; } // Rejoin in case the glob itself contains colons, then trim const pathPattern = parts.slice(2).join(':').trim(); if (!pathPattern) { this.logger.warn( `authzPerRoute: ignoring rule with empty path pattern in entry: ${entry}` ); return; } let pathRe: RegExp; try { pathRe = globToRegex(pathPattern); } catch { this.logger.warn( `authzPerRoute: ignoring entry with invalid glob "${pathPattern}" in rule: ${entry}` ); return; } this.addRule(user, { kind: 'method-path', method, pathRe }); return; } this.logger.warn(`authzPerRoute: ignoring invalid rule entry: ${entry}`); } private addRule(user: string, rule: AuthzRule): void { const existing = this.rules.get(user); if (existing) { existing.push(rule); } else { this.rules.set(user, [rule]); } } private matches(rules: AuthzRule[], method: string, path: string): boolean { for (const rule of rules) { if (rule.kind === 'wildcard') return true; if ( (rule.method === '*' || rule.method === method.toUpperCase()) && rule.pathRe.test(path) ) { return true; } } return false; } api(app: Express): void { app.use((req: Request, res: Response, next: NextFunction) => { const user = (req as DmRequest).user; // No authenticated user yet — let upstream auth plugin handle 401 if (!user) { return next(); } const rules = this.rules.get(user); if (!rules) { this.logger.warn( `authzPerRoute: user '${user}' denied for ${req.method} ${req.path} (no rules configured)` ); return forbidden(res); } if (this.matches(rules, req.method, req.path)) { return next(); } this.logger.warn( `authzPerRoute: user '${user}' denied for ${req.method} ${req.path}` ); return forbidden(res); }); } } linagora-ldap-rest-16e557e/src/plugins/auth/crowdsec.ts000066400000000000000000000120631522642357000231440ustar00rootroot00000000000000/** * @module core/auth/crowdsec * CrowdSec integration plugin to block banned IPs * * This plugin integrates with CrowdSec Local API to check if incoming * requests are from IPs with active ban decisions. It should be loaded * BEFORE authentication plugins to block malicious IPs early. * * Configuration: * - crowdsec_url: CrowdSec Local API URL (default: http://localhost:8080) * - crowdsec_api_key: API key for bouncer authentication (required) * - crowdsec_cache_ttl: Cache TTL in seconds for decisions (default: 60) * * @author Xavier Guimard */ import type { Express, Request, Response, NextFunction } from 'express'; import fetch from 'node-fetch'; import DmPlugin, { Role } from '../../abstract/plugin'; interface CrowdSecDecision { duration: string; id: number; origin: string; scenario: string; scope: string; type: string; value: string; } interface DecisionCache { isBanned: boolean; expiresAt: number; } export default class CrowdSec extends DmPlugin { name = 'crowdsec'; roles: Role[] = ['protect'] as const; private apiUrl: string; private apiKey: string; private cacheTtl: number; private cache: Map = new Map(); constructor(...args: ConstructorParameters) { super(...args); this.apiUrl = this.config.crowdsec_url || 'http://localhost:8080/v1/decisions'; this.apiKey = this.config.crowdsec_api_key as string; this.cacheTtl = (this.config.crowdsec_cache_ttl as number) || 60; if (!this.apiKey) { throw new Error( 'CrowdSec plugin requires crowdsec_api_key configuration' ); } this.logger.info( `CrowdSec integration enabled with API at ${this.apiUrl} (cache TTL: ${this.cacheTtl}s)` ); } api(app: Express): void { // Middleware to check IPs against CrowdSec decisions app.use(async (req: Request, res: Response, next: NextFunction) => { const ip = this.getClientIp(req); try { const isBanned = await this.checkIpBanned(ip); if (isBanned) { this.logger.warn(`Blocked request from CrowdSec-banned IP: ${ip}`); return res.status(403).json({ error: 'Access denied', reason: 'IP address is banned by security policies', }); } next(); } catch (error) { // Log error but don't block request if CrowdSec is unavailable this.logger.error( `CrowdSec API error for IP ${ip}: ${error instanceof Error ? error.message : String(error)}` ); // Fail open: allow request to proceed if CrowdSec is unavailable next(); } }); } private async checkIpBanned(ip: string): Promise { // Check cache first const cached = this.cache.get(ip); const now = Date.now(); if (cached && now < cached.expiresAt) { this.logger.debug(`CrowdSec cache hit for IP ${ip}: ${cached.isBanned}`); return cached.isBanned; } // Query CrowdSec API const url = `${this.apiUrl}?ip=${encodeURIComponent(ip)}`; this.logger.debug(`Querying CrowdSec API: ${url}`); const response = await fetch(url, { headers: { 'X-Api-Key': this.apiKey, }, }); if (!response.ok) { throw new Error( `CrowdSec API returned status ${response.status}: ${response.statusText}` ); } // Parse JSON response, handling edge cases let data: unknown; try { const text = await response.text(); // CrowdSec may return string "null" instead of actual null if (text === 'null' || text === '') { data = null; } else { data = JSON.parse(text) as unknown; } } catch (error) { this.logger.error( `Failed to parse CrowdSec API response: ${error instanceof Error ? error.message : String(error)}` ); throw new Error(`Invalid JSON response from CrowdSec API`); } // CrowdSec returns null if no decision, or an array of decisions let isBanned = false; if (data !== null && Array.isArray(data)) { // Check if there's any active ban decision isBanned = (data as CrowdSecDecision[]).some( decision => decision.type === 'ban' ); } // Cache the result this.cache.set(ip, { isBanned, expiresAt: now + this.cacheTtl * 1000, }); this.logger.debug( `CrowdSec decision for IP ${ip}: ${isBanned ? 'BANNED' : 'ALLOWED'}` ); return isBanned; } private getClientIp(req: Request): string { // Support X-Forwarded-For header for proxied requests // The header can be string | string[] | undefined const forwarded = req.headers['x-forwarded-for']; if (forwarded) { // If it's an array, take the first element, otherwise use the string directly const forwardedStr = Array.isArray(forwarded) ? forwarded[0] : forwarded; // X-Forwarded-For can contain multiple IPs separated by commas (client, proxy1, proxy2...) // The first IP is the original client return forwardedStr.split(',')[0].trim(); } return req.socket.remoteAddress || 'unknown'; } } linagora-ldap-rest-16e557e/src/plugins/auth/hmac.ts000066400000000000000000000150321522642357000222420ustar00rootroot00000000000000/** * @module plugins/auth/hmac * @author Xavier Guimard * * HMAC-SHA256 request signing authentication plugin * For backend services (Registration Service, admin panel backend, cloudery) * * Authorization: HMAC-SHA256 service-id:timestamp:signature * * Signature = HMAC-SHA256(secret, "METHOD|PATH|timestamp|body-hash") * where: * - METHOD: HTTP method (GET, POST, PATCH, DELETE, etc.) * - PATH: Request path with query string * - timestamp: Unix timestamp in milliseconds * - body-hash: SHA256(request_body) for POST/PATCH, empty for GET/DELETE * * @group Plugins */ import { createHmac, createHash, timingSafeEqual } from 'crypto'; import type { Response } from 'express'; import { unauthorized } from '../../lib/expressFormatedResponses'; import AuthBase, { type DmRequest } from '../../lib/auth/base'; import type { Role } from '../../abstract/plugin'; interface HmacService { id: string; secret: string; name: string; } export default class AuthHmac extends AuthBase { name = 'authHmac'; roles: Role[] = ['auth'] as const; private services: Map = new Map(); private timeWindow: number; // Time window in milliseconds for replay attack prevention constructor(...args: ConstructorParameters) { super(...args); // Parse HMAC configuration // Format: "service-id:secret:name" const hmacConfig = this.config.auth_hmac as string[]; this.timeWindow = (this.config.auth_hmac_window as number) ?? 120000; // Default: 2 minutes if (hmacConfig && Array.isArray(hmacConfig)) { hmacConfig.forEach((entry, index) => { const parts = entry.split(':'); if (parts.length >= 3) { const id = parts[0].trim(); const secret = parts[1].trim(); const name = parts.slice(2).join(':').trim(); // Allow colons in name if (!id || !secret || !name) { this.logger.warn( `Invalid HMAC config at index ${index}: missing id, secret, or name` ); return; } if (secret.length < 32) { this.logger.warn( `HMAC secret for service "${id}" is too short (minimum 32 characters recommended)` ); } this.services.set(id, { id, secret, name }); } else { this.logger.warn( `Invalid HMAC config format at index ${index}: expected "service-id:secret:name"` ); } }); } if (this.services.size === 0) { this.logger.warn('No valid HMAC services configured'); } else { this.logger.info( `HMAC authentication initialized with ${this.services.size} service(s): ${Array.from(this.services.keys()).join(', ')}` ); this.logger.info( `Time window for replay protection: ${this.timeWindow}ms` ); } } authMethod(req: DmRequest, res: Response, next: () => void): void { const authHeader = req.headers['authorization']; if (!authHeader || !authHeader.startsWith('HMAC-SHA256 ')) { this.logger.warn( 'Missing or invalid Authorization header (expected HMAC-SHA256)' ); return unauthorized(res); } // Extract: service-id:timestamp:signature const authValue = authHeader.substring('HMAC-SHA256 '.length); const parts = authValue.split(':'); if (parts.length !== 3) { this.logger.warn( 'Invalid HMAC authorization format (expected service-id:timestamp:signature)' ); return unauthorized(res); } const [serviceId, timestampStr, providedSignature] = parts; // Validate service exists const service = this.services.get(serviceId); if (!service) { this.logger.warn(`Unknown service ID: ${serviceId}`); return unauthorized(res); } // Validate timestamp format const timestamp = parseInt(timestampStr, 10); if (isNaN(timestamp) || timestamp <= 0) { this.logger.warn(`Invalid timestamp: ${timestampStr}`); return unauthorized(res); } // Check timestamp is within allowed window (prevent replay attacks) const now = Date.now(); const timeDiff = Math.abs(now - timestamp); if (timeDiff > this.timeWindow) { this.logger.warn( `Timestamp outside allowed window: ${timeDiff}ms (max: ${this.timeWindow}ms) for service ${serviceId}` ); return unauthorized(res); } // Calculate body hash const bodyHash = this.calculateBodyHash(req); // Reconstruct signing string const method = req.method.toUpperCase(); const path = req.originalUrl || req.url; const signingString = `${method}|${path}|${timestamp}|${bodyHash}`; // Calculate expected signature const expectedSignature = this.calculateHmac(service.secret, signingString); // Constant-time comparison to prevent timing attacks if (!this.secureCompare(providedSignature, expectedSignature)) { this.logger.warn( `Invalid signature for service ${serviceId} (${service.name}). ` + `Expected: ${expectedSignature.substring(0, 8)}..., ` + `Got: ${providedSignature.substring(0, 8)}...` ); this.logger.debug(`Signing string: ${signingString}`); return unauthorized(res); } // Authentication successful this.logger.debug( `HMAC authentication successful for service: ${serviceId} (${service.name})` ); req.user = service.name; next(); } /** * Calculate HMAC-SHA256 signature */ private calculateHmac(secret: string, data: string): string { const hmac = createHmac('sha256', secret); hmac.update(data); return hmac.digest('hex'); } /** * Calculate SHA256 hash of request body * Returns empty string for GET/DELETE/HEAD methods */ private calculateBodyHash(req: DmRequest): string { const method = req.method.toUpperCase(); // No body for these methods if (method === 'GET' || method === 'DELETE' || method === 'HEAD') return ''; // Hash the body if present if (req.body) { const bodyString = typeof req.body === 'string' ? req.body : JSON.stringify(req.body); const hash = createHash('sha256'); hash.update(bodyString); return hash.digest('hex'); } return ''; } /** * Constant-time string comparison to prevent timing attacks */ private secureCompare(a: string, b: string): boolean { if (a.length !== b.length) { return false; } try { const bufA = Buffer.from(a, 'utf8'); const bufB = Buffer.from(b, 'utf8'); return timingSafeEqual(bufA, bufB); } catch { return false; } } } linagora-ldap-rest-16e557e/src/plugins/auth/llng.ts000066400000000000000000000013251522642357000222660ustar00rootroot00000000000000/** * @module plugins/auth/llng * @group Plugins * @author Xavier Guimard * * Lemonldap::NG authentication plugin * This plugin enables authentication and authorization using Lemonldap::NG. */ import * as llng from 'lemonldap-ng-handler'; import type { Response } from 'express'; import AuthBase, { DmRequest } from '../../lib/auth/base'; import type { Role } from '../../abstract/plugin'; export default class AuthLLNG extends AuthBase { name = 'authLemonldapNg'; roles: Role[] = ['auth'] as const; authMethod(req: DmRequest, res: Response, next: () => void): void { llng.run(req, res, () => { req.user = req.headers['Lm-Remote-User'] as string; next(); }); } } linagora-ldap-rest-16e557e/src/plugins/auth/openidconnect.ts000066400000000000000000000045541522642357000241710ustar00rootroot00000000000000import type { Express, Response } from 'express'; import { auth, ConfigParams } from 'express-openid-connect'; import { DmRequest } from '../../lib/auth/base'; import DmPlugin, { type Role } from '../../abstract/plugin'; import { launchHooksChained } from '../../lib/utils'; import { serverError } from '../../lib/expressFormatedResponses'; import { DM } from '../../bin'; export default class OpenIDConnect extends DmPlugin { name = 'openidconnect'; roles: Role[] = ['auth'] as const; constructor(server: DM) { super(server); for (const p of [ 'oidc_server', 'oidc_client_id', 'oidc_client_secret', 'base_url', ]) { if (!this.config[p]) throw new Error(`Missing config parameter ${p}`); } } api(app: Express): void { const config: ConfigParams = { authRequired: true, issuerBaseURL: this.config.oidc_server, clientID: this.config.oidc_client_id, secret: this.config.oidc_client_secret as string, baseURL: this.config.base_url as string, clientSecret: this.config.oidc_client_secret as string, authorizationParams: { response_type: 'code', scope: 'openid profile email', }, }; app.use(async (req, res, next) => { try { [req, res] = await launchHooksChained(this.server.hooks.beforeAuth, [ req, res, ]); next(); } catch (err) { return serverError(res, err as Error); } }); app.use(auth(config)); app.use(async (req, res, next) => { try { if (this.hooks?.onAuth) { [req, res] = await launchHooksChained(this.server.hooks.afterAuth, [ req, res, ]); } // @ts-expect-error request is augmented by express-openid-connect // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment req.user = req.oidc.user.sub; next(); } catch (err) { serverError(res, err as Error); } }); } authMethod(req: DmRequest, res: Response, next: () => void): void { auth({ issuerBaseURL: process.env.ISSUER_BASE_URL, clientID: process.env.CLIENT_ID, clientSecret: process.env.CLIENT_SECRET, baseURL: process.env.BASE_URL, authorizationParams: { response_type: 'code', scope: 'openid email profile', }, })(req, res, next); } } linagora-ldap-rest-16e557e/src/plugins/auth/rateLimit.ts000066400000000000000000000070141522642357000232650ustar00rootroot00000000000000/** * @module core/auth/rateLimit * Rate limiting plugin to prevent brute-force attacks * * This plugin adds rate limiting middleware specifically for authentication * to protect against brute-force attacks. It should be loaded BEFORE auth plugins. * * Uses an Express middleware to check rate limits before processing, and * an afterAuth hook to track failed authentication attempts. * * @author Xavier Guimard */ import type { Express, Request, Response, NextFunction } from 'express'; import DmPlugin, { type Role } from '../../abstract/plugin'; interface RateLimitEntry { count: number; resetTime: number; } export default class RateLimit extends DmPlugin { name = 'rateLimit'; roles: Role[] = ['protect'] as const; private store: Map = new Map(); private windowMs: number; private maxRequests: number; constructor(...args: ConstructorParameters) { super(...args); this.windowMs = this.config.rate_limit_window_ms || 15 * 60 * 1000; // 15 minutes this.maxRequests = this.config.rate_limit_max || 100; // 100 requests per window this.logger.info( `Auth rate limiting enabled: ${this.maxRequests} failed attempts per ${this.windowMs / 1000} seconds` ); } api(app: Express): void { // Middleware to check rate limits and track failed authentications app.use((req: Request, res: Response, next: NextFunction) => { const ip = this.getClientIp(req); const now = Date.now(); const entry = this.store.get(ip); // Clean up expired entries if (entry && now > entry.resetTime) { this.store.delete(ip); } // Check if IP is rate limited const currentEntry = this.store.get(ip); if ( currentEntry && currentEntry.count >= this.maxRequests && now <= currentEntry.resetTime ) { this.logger.warn( `Rate limiting ${ip}: ${currentEntry.count}/${this.maxRequests} failed attempts` ); return res.status(429).json({ error: 'Too many authentication attempts, please try again later', retryAfter: Math.ceil((currentEntry.resetTime - now) / 1000), }); } // Track failed auth attempts using the 'finish' event res.on('finish', () => { if (res.statusCode === 401 || res.statusCode === 403) { const entry = this.store.get(ip); const now = Date.now(); if (!entry || now > entry.resetTime) { // Create new entry this.store.set(ip, { count: 1, resetTime: now + this.windowMs, }); } else { // Increment existing entry entry.count++; } this.logger.debug( `Failed auth from ${ip}: ${this.store.get(ip)?.count}/${this.maxRequests}` ); } }); next(); }); } private getClientIp(req: Request): string { // Support X-Forwarded-For header for proxied requests // The header can be string | string[] | undefined const forwarded = req.headers['x-forwarded-for']; if (forwarded) { // If it's an array, take the first element, otherwise use the string directly const forwardedStr = Array.isArray(forwarded) ? forwarded[0] : forwarded; // X-Forwarded-For can contain multiple IPs separated by commas (client, proxy1, proxy2...) // The first IP is the original client return forwardedStr.split(',')[0].trim(); } return req.socket.remoteAddress || 'unknown'; } } linagora-ldap-rest-16e557e/src/plugins/auth/token.ts000066400000000000000000000034671522642357000224630ustar00rootroot00000000000000/** * @module plugins/auth/token * @author Xavier Guimard * * Token-based authentication plugin * @group Plugins */ import type { Response } from 'express'; import { unauthorized } from '../../lib/expressFormatedResponses'; import AuthBase, { type DmRequest } from '../../lib/auth/base'; import type { Role } from '../../abstract/plugin'; export default class AuthToken extends AuthBase { name = 'authToken'; roles: Role[] = ['auth'] as const; private tokenMap: Map = new Map(); // token -> name constructor(...args: ConstructorParameters) { super(...args); // Parse tokens and build token map const tokens = this.config.auth_token as string[]; if (tokens && Array.isArray(tokens)) { tokens.forEach((tokenEntry, index) => { if (tokenEntry.includes(':')) { // Format: "token:name" const [token, name] = tokenEntry.split(':', 2); this.tokenMap.set(token.trim(), name.trim()); } else { // Legacy format: just token, use index as name this.tokenMap.set(tokenEntry.trim(), `token ${index}`); } }); } } authMethod(req: DmRequest, res: Response, next: () => void): void { let token = req.headers['authorization']; if (!token || !/^Bearer .+/.test(token)) { this.logger.warn('Missing or invalid Authorization header'); return unauthorized(res); } token = token.split(' ')[1]; const userName = this.tokenMap.get(token); if (!userName) { // Mask token in logs to prevent credential exposure const maskedToken = token.length > 8 ? `${token.substring(0, 8)}...` : '***'; this.logger.warn(`Unauthorized token: ${maskedToken}`); return unauthorized(res); } req.user = userName; next(); } } linagora-ldap-rest-16e557e/src/plugins/auth/totp.ts000066400000000000000000000127661522642357000223330ustar00rootroot00000000000000/** * @module plugins/auth/totp * @author Xavier Guimard * * TOTP-based authentication plugin * @group Plugins */ import { createHmac } from 'crypto'; import type { Response } from 'express'; import { unauthorized } from '../../lib/expressFormatedResponses'; import AuthBase, { type DmRequest } from '../../lib/auth/base'; import type { Role } from '../../abstract/plugin'; interface TotpUser { name: string; secret: string; // Base32 encoded secret digits: number; // Number of digits in TOTP code } export default class AuthTotp extends AuthBase { name = 'authTotp'; roles: Role[] = ['auth'] as const; private totpUsers: TotpUser[] = []; private window: number; // Time window for validation (±window * step seconds) private step: number; // Time step in seconds (typically 30) constructor(...args: ConstructorParameters) { super(...args); // Parse TOTP configuration const totpConfig = this.config.auth_totp as string[]; this.window = this.config.auth_totp_window ?? 1; this.step = this.config.auth_totp_step ?? 30; if (totpConfig && Array.isArray(totpConfig)) { totpConfig.forEach((entry, index) => { const parts = entry.split(':'); if (parts.length >= 2) { // Format: "secret:name" or "secret:name:digits" const secret = parts[0].trim(); const name = parts[1].trim(); const digits = parts[2] ? parseInt(parts[2].trim(), 10) : 6; if (!this.isValidBase32(secret)) { this.logger.warn( `Invalid Base32 secret for TOTP user "${name}" (index ${index})` ); return; } if (digits < 6 || digits > 10) { this.logger.warn( `Invalid digits count for TOTP user "${name}": ${digits} (must be 6-10)` ); return; } this.totpUsers.push({ name, secret, digits }); } else { this.logger.warn( `Invalid TOTP config format at index ${index}: expected "secret:name[:digits]"` ); } }); } if (this.totpUsers.length === 0) { this.logger.warn('No valid TOTP users configured'); } else { this.logger.info( `TOTP authentication initialized with ${this.totpUsers.length} user(s)` ); } } authMethod(req: DmRequest, res: Response, next: () => void): void { const authHeader = req.headers['authorization']; if (!authHeader || !/^Bearer .+/.test(authHeader)) { this.logger.warn('Missing or invalid Authorization header'); return unauthorized(res); } const token = authHeader.split(' ')[1]; // Try to validate token against all configured users for (const user of this.totpUsers) { if (this.verifyTotp(user.secret, token, user.digits)) { this.logger.debug( `TOTP authentication successful for user: ${user.name}` ); req.user = user.name; return next(); } } this.logger.warn(`Unauthorized TOTP token: ${token}`); return unauthorized(res); } /** * Verify a TOTP token against a secret */ private verifyTotp(secret: string, token: string, digits: number): boolean { const now = Math.floor(Date.now() / 1000); // Check current time window and adjacent windows for (let i = -this.window; i <= this.window; i++) { const counter = Math.floor(now / this.step) + i; const expectedToken = this.generateTotp(secret, counter, digits); if (expectedToken === token) { return true; } } return false; } /** * Generate a TOTP token using HMAC-SHA1 */ private generateTotp( secret: string, counter: number, digits: number ): string { // Decode Base32 secret const key = this.base32Decode(secret); // Convert counter to 8-byte buffer (big-endian) const counterBuffer = Buffer.alloc(8); counterBuffer.writeBigUInt64BE(BigInt(counter)); // Generate HMAC-SHA1 const hmac = createHmac('sha1', key); hmac.update(counterBuffer); const hash = hmac.digest(); // Dynamic truncation (RFC 4226) const offset = hash[hash.length - 1] & 0x0f; const truncatedHash = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16) | ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff); // Generate N-digit code const code = truncatedHash % Math.pow(10, digits); return code.toString().padStart(digits, '0'); } /** * Decode a Base32 string to Buffer */ private base32Decode(input: string): Buffer { const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; // Remove trailing padding characters (avoid ReDoS by using simple loop instead of regex) let cleanInput = input.toUpperCase(); while (cleanInput.endsWith('=')) { cleanInput = cleanInput.slice(0, -1); } let bits = 0; let value = 0; const output: number[] = []; for (let i = 0; i < cleanInput.length; i++) { const idx = base32Chars.indexOf(cleanInput[i]); if (idx === -1) { throw new Error(`Invalid Base32 character: ${cleanInput[i]}`); } value = (value << 5) | idx; bits += 5; if (bits >= 8) { output.push((value >>> (bits - 8)) & 0xff); bits -= 8; } } return Buffer.from(output); } /** * Validate if a string is valid Base32 */ private isValidBase32(input: string): boolean { const base32Regex = /^[A-Z2-7]+=*$/; return base32Regex.test(input.toUpperCase()); } } linagora-ldap-rest-16e557e/src/plugins/auth/trustedProxy.ts000066400000000000000000000205571522642357000240760ustar00rootroot00000000000000/** * @module core/auth/trustedProxy * Trusted proxy plugin to validate X-Forwarded-For headers * * This plugin validates that X-Forwarded-For headers only come from trusted * upstream reverse proxies. If the request comes from an untrusted IP, * the X-Forwarded-For header is removed to prevent IP spoofing attacks. * * This plugin MUST be loaded BEFORE any plugin that uses X-Forwarded-For * (like crowdsec and rateLimit) to ensure they receive sanitized headers. * * Configuration: * - trusted_proxy: Array of trusted proxy IPs or CIDR ranges (required) * Example: ["127.0.0.1", "10.0.0.0/8", "192.168.1.0/24", "::1"] * - trusted_proxy_auth_header: Header name containing the authenticated user * from trusted proxy (optional, default: "Auth-User") * * When a request comes from a trusted proxy: * - X-Forwarded-For header is preserved * - The request is marked as trusted (req.trustedProxy = true) * - If trusted_proxy_auth_header is set and the header is present, * req.proxyAuthUser is set to its value for use by other plugins (e.g., weblogs) * * @author Xavier Guimard */ import type { Express, Request, Response, NextFunction } from 'express'; import DmPlugin, { type Role } from '../../abstract/plugin'; // Extend Express Request to include trusted proxy info declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Express { interface Request { trustedProxy?: boolean; proxyAuthUser?: string; } } } interface ParsedCIDR { ip: bigint; mask: bigint; isIPv6: boolean; } export default class TrustedProxy extends DmPlugin { name = 'trustedProxy'; roles: Role[] = ['protect'] as const; private trustedNetworks: ParsedCIDR[] = []; private authHeader: string; constructor(...args: ConstructorParameters) { super(...args); const trustedProxies = this.config.trusted_proxy; if ( !trustedProxies || !Array.isArray(trustedProxies) || trustedProxies.length === 0 ) { throw new Error( 'TrustedProxy plugin requires trusted_proxy configuration (array of IPs or CIDR ranges)' ); } // Parse all trusted proxy addresses/ranges for (const proxy of trustedProxies) { try { this.trustedNetworks.push(this.parseCIDR(proxy)); } catch (error) { throw new Error( `Invalid trusted proxy address "${proxy}": ${error instanceof Error ? error.message : String(error)}` ); } } // Get auth header name (default: Auth-User) this.authHeader = ( (this.config.trusted_proxy_auth_header as string) || 'Auth-User' ).toLowerCase(); this.logger.info( `Trusted proxy validation enabled for ${trustedProxies.length} address(es): ${trustedProxies.join(', ')} (auth header: ${this.authHeader})` ); } api(app: Express): void { // Middleware to validate X-Forwarded-For headers app.use((req: Request, _res: Response, next: NextFunction) => { const remoteAddr = req.socket.remoteAddress; if (!remoteAddr) { // No remote address, remove X-Forwarded-For to be safe this.logger.debug('No remote address, removing X-Forwarded-For header'); delete req.headers['x-forwarded-for']; return next(); } // Check if request comes from a trusted proxy const xff = req.headers['x-forwarded-for']; if (this.isTrustedProxy(remoteAddr)) { // Mark request as coming from trusted proxy req.trustedProxy = true; // Extract auth user from header if present const authUser = req.headers[this.authHeader]; if (authUser) { req.proxyAuthUser = Array.isArray(authUser) ? authUser[0] : authUser; this.logger.debug( `Request from trusted proxy ${remoteAddr}, Auth-User: ${req.proxyAuthUser}, X-Forwarded-For: ${xff ? String(xff) : '(none)'}` ); } else { this.logger.debug( `Request from trusted proxy ${remoteAddr}, X-Forwarded-For: ${xff ? String(xff) : '(none)'}` ); } } else { // Untrusted source: remove X-Forwarded-For to prevent spoofing req.trustedProxy = false; if (xff) { this.logger.warn( `Removing X-Forwarded-For header from untrusted source ${remoteAddr} (was: ${String(xff)})` ); delete req.headers['x-forwarded-for']; } } next(); }); } /** * Check if an IP address belongs to a trusted proxy network */ private isTrustedProxy(ip: string): boolean { try { const normalizedIp = this.normalizeIP(ip); const parsed = this.parseIP(normalizedIp); for (const network of this.trustedNetworks) { // Skip if IP versions don't match (unless it's a mapped IPv4) if (parsed.isIPv6 !== network.isIPv6) { continue; } if ((parsed.ip & network.mask) === (network.ip & network.mask)) { return true; } } return false; } catch (error) { this.logger.error( `Failed to parse IP address "${ip}": ${error instanceof Error ? error.message : String(error)}` ); return false; } } /** * Normalize IP address (handle IPv4-mapped IPv6 addresses) */ private normalizeIP(ip: string): string { // Handle IPv4-mapped IPv6 addresses like ::ffff:127.0.0.1 const ipv4Mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); if (ipv4Mapped) { return ipv4Mapped[1]; } return ip; } /** * Parse an IP address into a bigint for comparison */ private parseIP(ip: string): { ip: bigint; isIPv6: boolean } { if (ip.includes(':')) { return { ip: this.parseIPv6(ip), isIPv6: true }; } return { ip: this.parseIPv4(ip), isIPv6: false }; } /** * Parse a CIDR notation string into network address and mask */ private parseCIDR(cidr: string): ParsedCIDR { const [addr, prefixStr] = cidr.split('/'); const isIPv6 = addr.includes(':'); let ip: bigint; let mask: bigint; const maxPrefix = isIPv6 ? 128 : 32; if (isIPv6) { ip = this.parseIPv6(addr); } else { ip = this.parseIPv4(addr); } if (prefixStr !== undefined) { const prefix = parseInt(prefixStr, 10); if (isNaN(prefix) || prefix < 0 || prefix > maxPrefix) { throw new Error(`Invalid prefix length: ${prefixStr}`); } // Create mask with 'prefix' number of 1s followed by 0s if (prefix === 0) { mask = 0n; } else { const totalBits = BigInt(maxPrefix); mask = ((1n << totalBits) - 1n) ^ ((1n << (totalBits - BigInt(prefix))) - 1n); } } else { // No prefix means exact match (all bits set) mask = isIPv6 ? (1n << 128n) - 1n : (1n << 32n) - 1n; } return { ip, mask, isIPv6 }; } /** * Parse an IPv4 address into a bigint */ private parseIPv4(ip: string): bigint { const parts = ip.split('.'); if (parts.length !== 4) { throw new Error(`Invalid IPv4 address: ${ip}`); } let result = 0n; for (const part of parts) { const num = parseInt(part, 10); if (isNaN(num) || num < 0 || num > 255) { throw new Error(`Invalid IPv4 address: ${ip}`); } result = (result << 8n) | BigInt(num); } return result; } /** * Parse an IPv6 address into a bigint */ private parseIPv6(ip: string): bigint { // Expand :: shorthand let fullIp = ip; if (ip.includes('::')) { const parts = ip.split('::'); if (parts.length > 2) { throw new Error(`Invalid IPv6 address: ${ip}`); } const left = parts[0] ? parts[0].split(':') : []; const right = parts[1] ? parts[1].split(':') : []; const missing = 8 - left.length - right.length; if (missing < 0) { throw new Error(`Invalid IPv6 address: ${ip}`); } const middle: string[] = Array(missing).fill('0') as string[]; fullIp = [...left, ...middle, ...right].join(':'); } const groups = fullIp.split(':'); if (groups.length !== 8) { throw new Error(`Invalid IPv6 address: ${ip}`); } let result = 0n; for (const group of groups) { const num = parseInt(group || '0', 16); if (isNaN(num) || num < 0 || num > 0xffff) { throw new Error(`Invalid IPv6 address: ${ip}`); } result = (result << 16n) | BigInt(num); } return result; } } linagora-ldap-rest-16e557e/src/plugins/configApi.ts000066400000000000000000000122331522642357000222700ustar00rootroot00000000000000/** * @module plugins/configApi * @author Xavier Guimard * * Plugin that exposes API configuration for LDAP editor applications * Provides information about available resources, schemas, and endpoints * * Uses autodiscovery pattern: plugins with 'configurable' role automatically * expose their configuration via getConfigApiData() method */ import type { Express, Request, Response } from 'express'; import DmPlugin, { type Role } from '../abstract/plugin'; import { wantJson } from '../lib/expressFormatedResponses'; interface ConfigApiResponse { apiPrefix: string; ldapBase: string; features: Record; } /** * @openapi-component * Config: * type: object * description: | * Aggregated server configuration. The top-level fields are always * present; `features` is a dynamic map whose keys are the names of * loaded plugins that declare the `configurable` role. Each plugin * contributes an arbitrary sub-object — consult the individual plugin * documentation for its exact shape. * additionalProperties: true * properties: * apiPrefix: * type: string * description: The API base path configured on this server. * example: /api * ldapBase: * type: string * description: The root LDAP suffix used by this server. * example: dc=example,dc=com * features: * type: object * description: | * Per-plugin configuration blocks. Keys match the plugin's * `name` field; values are plugin-specific objects. * additionalProperties: true * example: * ldapGroups: * enabled: true * base: ou=groups,dc=example,dc=com * mainAttribute: cn * objectClass: [top, groupOfNames] * static: * enabled: true * staticPath: /static * appAccountsApi: * enabled: true * base: ou=app-accounts,dc=example,dc=com * maxAccounts: 5 * example: * apiPrefix: /api * ldapBase: dc=example,dc=com * features: * ldapGroups: * enabled: true * base: ou=groups,dc=example,dc=com * mainAttribute: cn * static: * enabled: true * staticPath: /static */ export default class ConfigApi extends DmPlugin { name = 'configApi'; roles: Role[] = ['api'] as const; /** * API routes */ api(app: Express): void { const apiPrefix = this.config.api_prefix || '/api'; /** * @openapi * summary: Get server configuration * description: | * Returns aggregated runtime configuration for all loaded plugins. * Plugins that declare the `configurable` role expose a named block * under `features` via their `getConfigApiData()` method. The * response shape is open-ended (`additionalProperties: true`) because * the exact keys depend on which plugins are active. * * Typical consumers include browser-embedded LDAP editors that need * to discover available schemas, endpoint prefixes, and feature flags * without hard-coding server topology. * responses: * '200': * description: Server configuration. * content: * application/json: * schema: { $ref: '#/components/schemas/Config' } * example: * apiPrefix: /api * ldapBase: dc=example,dc=com * features: * ldapGroups: * enabled: true * base: ou=groups,dc=example,dc=com * mainAttribute: cn * objectClass: [top, groupOfNames] * static: * enabled: true * staticPath: /static * endpoints: * schema: /static/schemas/:name * schemaInSubdir: /static/schemas/:dir/:name */ app.get(`${apiPrefix}/v1/config`, (req: Request, res: Response) => { if (!wantJson(req, res)) return; const config: ConfigApiResponse = { apiPrefix: apiPrefix, ldapBase: this.config.ldap_base || '', features: this.collectPluginConfigs(), }; res.json(config); }); this.logger.info(`Configuration API registered at ${apiPrefix}/v1/config`); } /** * Collect configuration from all plugins with 'configurable' role */ private collectPluginConfigs(): Record { const features: Record = {}; // Iterate through all loaded plugins for (const [pluginName, plugin] of Object.entries( this.server.loadedPlugins )) { // Skip self if (pluginName === 'configApi') continue; // Check if plugin has 'configurable' role if (plugin.roles?.includes('configurable') && plugin.getConfigApiData) { try { const pluginConfig = plugin.getConfigApiData(); if (pluginConfig) { features[pluginName] = pluginConfig; } } catch (err) { this.logger.warn( `Failed to get config from plugin ${pluginName}: ${String(err)}` ); } } } return features; } } linagora-ldap-rest-16e557e/src/plugins/demo/000077500000000000000000000000001522642357000207445ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/plugins/demo/helloworld.ts000066400000000000000000000042221522642357000234670ustar00rootroot00000000000000/** * @module core/helloworld * Demo plugin, add /hello API + consumes 'hello' hook * @author Xavier Guimard */ import type { Express, Request, Response } from 'express'; import DmPlugin, { type Role, asyncHandler } from '../../abstract/plugin'; /** * @openapi-component * Hello: * type: object * description: Response returned by the hello-world demo endpoint. * properties: * message: * type: string * description: Static greeting string. * example: Hello * hookResults: * type: array * description: | * Results collected from every function registered on the `hello` * hook. Empty when no hook listeners are installed. * items: {} * example: [] * example: * message: Hello * hookResults: [] */ export default class HelloWorld extends DmPlugin { name = 'hello'; roles: Role[] = ['demo'] as const; api(app: Express): void { /** * @openapi * summary: Demo hello-world endpoint * description: | * Exists exclusively as a minimal example for plugin development. * The handler fires the `hello` hook and collects the results from * every registered listener into `hookResults`. In a production * deployment this plugin is normally disabled; enabling it is safe * but adds no functional value. * responses: * '200': * description: Greeting with hook results. * content: * application/json: * schema: { $ref: '#/components/schemas/Hello' } * example: * message: Hello * hookResults: [] */ app.get( `${this.config.api_prefix}/hello`, asyncHandler(async (req: Request, res: Response) => { const response = { message: 'Hello', hookResults: [] as unknown[] }; if (this.server.hooks && this.server.hooks['hello']) { for (const hook of this.server.hooks['hello']) { // eslint-disable-next-line @typescript-eslint/no-unsafe-call response.hookResults.push(await hook()); } } res.json(response); }) ); } } linagora-ldap-rest-16e557e/src/plugins/ldap/000077500000000000000000000000001522642357000207405ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/plugins/ldap/bulkImport.ts000066400000000000000000000440231522642357000234430ustar00rootroot00000000000000/** * LDAP Bulk Import Plugin * Provides CSV-based bulk import for LDAP resources based on JSON schemas * @author LDAP-Rest Team */ import fs from 'fs'; import type { Express, Request, Response } from 'express'; import type { SearchResult } from 'ldapts'; import multer from 'multer'; import { parse as csvParse } from 'csv-parse/sync'; import DmPlugin, { type Role } from '../../abstract/plugin'; import type { DM } from '../../bin'; import type { AttributesList, AttributeValue } from '../../lib/ldapActions'; import { badRequest, serverError } from '../../lib/expressFormatedResponses'; import { escapeDnValue, validateDnValue } from '../../lib/utils'; interface BulkImportSchema { base?: string; mainAttribute?: string; properties: { [key: string]: { type?: string; fixed?: boolean; default?: AttributeValue; const?: AttributeValue; required?: boolean; role?: string[]; }; }; } interface BulkImportResource { name: string; schema: BulkImportSchema; base: string; mainAttribute: string; } interface BulkImportResult { success: boolean; total: number; created: number; updated: number; skipped: number; failed: number; errors: Array<{ line: number; identifier?: string; error: string; }>; details: { duration: string; linesProcessed: number; }; } /** * Shared OpenAPI schemas surfaced by this plugin. Picked up by * scripts/generate-openapi.ts and merged into `components.schemas`. * * @openapi-component * BulkImportResult: * type: object * description: Summary returned after a bulk import operation. * required: [success, total, created, updated, skipped, failed, errors, details] * properties: * success: * type: boolean * description: True when no fatal error aborted the import early. * total: * type: integer * description: Total number of CSV data rows (excluding header). * example: 42 * created: * type: integer * description: Entries successfully created (or counted in dry-run). * example: 38 * updated: * type: integer * description: Existing entries updated. * example: 2 * skipped: * type: integer * description: Existing entries skipped because `updateExisting` was false. * example: 1 * failed: * type: integer * description: Rows that caused an error. * example: 1 * errors: * type: array * items: * type: object * required: [line, error] * properties: * line: * type: integer * description: 1-based CSV line number (2 = first data row). * identifier: * type: string * description: Value of the main attribute for the failing row (if known). * error: * type: string * description: Error message. * example: * - line: 5 * identifier: bob * error: 'Missing required attribute: mail' * details: * type: object * required: [duration, linesProcessed] * properties: * duration: * type: string * description: Wall-clock time for the import (e.g. `1.2s`). * example: 1.2s * linesProcessed: * type: integer * description: Total data rows attempted. * example: 42 */ export default class LdapBulkImport extends DmPlugin { name = 'ldapBulkImport'; roles: Role[] = ['configurable'] as const; private resources: Map = new Map(); private upload: multer.Multer; constructor(parent: DM) { super(parent); // Configure multer for file uploads const maxFileSize = parseInt(this.config.bulk_import_max_file_size as string, 10) || 10485760; // 10MB default this.upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: maxFileSize }, fileFilter: ( req: Express.Request, file: Express.Multer.File, cb: multer.FileFilterCallback ) => { if ( file.mimetype === 'text/csv' || file.originalname.endsWith('.csv') ) { cb(null, true); } else { cb(new Error('Only CSV files are allowed')); } }, }); // Load schemas configuration const schemasConfig = this.config.bulk_import_schemas as string; if (!schemasConfig) { this.logger.warn('No bulk import schemas configured'); return; } // Parse schemas config: "users:path/to/schema.json,groups:path/to/schema.json" const schemaEntries = schemasConfig.split(',').map(entry => entry.trim()); for (const entry of schemaEntries) { const [resourceName, schemaPath] = entry.split(':').map(s => s.trim()); if (!resourceName || !schemaPath) { this.logger.warn(`Invalid schema entry: ${entry}`); continue; } try { const schemaContent = fs.readFileSync(schemaPath, 'utf-8'); const schema = JSON.parse(schemaContent) as BulkImportSchema; this.resources.set(resourceName, { name: resourceName, schema, base: (schema.base || this.config.ldap_base) as string, mainAttribute: schema.mainAttribute || 'cn', }); this.logger.info( `Loaded bulk import resource: ${resourceName} from ${schemaPath}` ); } catch (error) { this.logger.error( `Failed to load schema for ${resourceName}: ${(error as Error).message}` ); } } } api(app: Express): void { for (const [resourceName, resource] of this.resources) { /** * @openapi * summary: Download CSV import template * description: | * Returns a CSV file with the header row listing all editable columns for * the `{resource}` type. Fixed, calculated, and organization-path columns * are excluded because they are injected automatically during import. * * Use this template as the starting point for a bulk import file. * responses: * '200': * description: CSV template file. * content: * text/csv: * schema: { type: string } * example: | * cn,sn,givenName,mail,organizationDn * '404': * description: Resource type not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // GET CSV template app.get( `${this.config.api_prefix}/v1/ldap/bulk-import/${resourceName}/template.csv`, (req, res) => this.getTemplate(req, res, resource) ); /** * @openapi * summary: Bulk import from CSV * description: | * Accepts a multipart/form-data upload containing a CSV file and optional * control flags. Each data row is mapped to an LDAP entry using the resource * schema. Multi-valued attributes can be expressed with semicolons * (`val1;val2;val3`). An `organizationDn` column (not listed in the template * header) may be provided to set the organization link and path attributes * automatically. * * **Dry-run mode** (`dryRun=true`) validates and counts rows without writing * to LDAP. `continueOnError` (default `true`) keeps processing subsequent rows * after an individual row fails. * requestBody: * required: true * content: * multipart/form-data: * schema: * type: object * required: [file] * properties: * file: * type: string * format: binary * description: CSV file to import (max 10 MB by default). * dryRun: * type: boolean * description: | * When true, validate and count rows without writing to LDAP. * updateExisting: * type: boolean * description: | * When true, existing entries are updated (replace). When false * (default), they are skipped. * continueOnError: * type: boolean * default: true * description: | * Continue processing subsequent rows after a row-level error. * responses: * '200': * description: Import result summary. * content: * application/json: * schema: { $ref: '#/components/schemas/BulkImportResult' } * example: * success: true * total: 10 * created: 9 * updated: 0 * skipped: 0 * failed: 1 * errors: * - line: 7 * identifier: charlie * error: 'Missing required attribute: mail' * details: * duration: 0.8s * linesProcessed: 10 * '400': * description: No file uploaded or malformed request. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } * '500': * description: Unexpected server error. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // POST bulk import app.post( `${this.config.api_prefix}/v1/ldap/bulk-import/${resourceName}`, this.upload.single('file'), async (req, res) => this.bulkImport(req, res, resource) ); } } /** * Generate CSV template from schema */ private getTemplate( req: Request, res: Response, resource: BulkImportResource ): void { const headers = this.getEditableAttributes(resource.schema); headers.push('organizationDn'); // Special column for organization link/path res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader( 'Content-Disposition', `attachment; filename="${resource.name}-template.csv"` ); res.send(headers.join(',') + '\n'); } /** * Bulk import from CSV file */ private async bulkImport( req: Request & { file?: Express.Multer.File }, res: Response, resource: BulkImportResource ): Promise { if (!req.file) { return badRequest(res, 'No file uploaded'); } const dryRun = (req.body as { dryRun?: string }).dryRun === 'true' || (req.body as { dryRun?: boolean }).dryRun === true; const updateExisting = (req.body as { updateExisting?: string }).updateExisting === 'true' || (req.body as { updateExisting?: boolean }).updateExisting === true; const continueOnError = (req.body as { continueOnError?: string }).continueOnError !== 'false' && (req.body as { continueOnError?: boolean }).continueOnError !== false; const startTime = Date.now(); const result: BulkImportResult = { success: true, total: 0, created: 0, updated: 0, skipped: 0, failed: 0, errors: [], details: { duration: '', linesProcessed: 0, }, }; try { // Parse CSV const csvContent = req.file.buffer.toString('utf-8'); // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const records = csvParse(csvContent, { columns: true, skip_empty_lines: true, trim: true, }) as Record[]; result.total = records.length; // Process each record for (let i = 0; i < records.length; i++) { const line = i + 2; // +2 because of header and 0-based index const record = records[i]; try { const { dn, entry } = await this.processRecord(record, resource); // const identifier = entry[resource.mainAttribute]; // Check if entry exists const exists = await this.entryExists(dn); if (exists && !updateExisting) { result.skipped++; continue; } if (dryRun) { result.created++; continue; } // Create or update entry if (exists && updateExisting) { await this.server.ldap.modify( dn, { replace: entry }, req as Request ); result.updated++; } else { await this.server.ldap.add(dn, entry, req as Request); result.created++; } } catch (error) { result.failed++; result.errors.push({ line, identifier: record[resource.mainAttribute], error: (error as Error).message, }); if (!continueOnError) { throw error; } } } result.details.linesProcessed = records.length; result.details.duration = `${((Date.now() - startTime) / 1000).toFixed(1)}s`; res.json(result); } catch (error) { return serverError(res, error); } } /** * Process a single CSV record */ private async processRecord( csvLine: Record, resource: BulkImportResource ): Promise<{ dn: string; entry: AttributesList }> { const entry: AttributesList = {}; const schema = resource.schema; // 1. Extract organizationDn const orgDn = csvLine.organizationDn; delete csvLine.organizationDn; // 2. Add fixed fields from schema for (const [attr, def] of Object.entries(schema.properties || {})) { if (def.fixed === true) { const value = def.default || def.const; if (value !== undefined) { entry[attr] = value; } } } // 3. Add fields from CSV for (const [attr, value] of Object.entries(csvLine)) { if (value && value.trim() !== '') { // Support multi-value: "val1;val2;val3" entry[attr] = value.includes(';') ? value.split(';').map(v => v.trim()) : value; } } // 4. Calculate organizationLink and organizationPath if (orgDn) { const linkAttr = this.findAttributeByRole(schema, 'organizationLink'); if (linkAttr) { entry[linkAttr] = orgDn; } const pathAttr = this.findAttributeByRole(schema, 'organizationPath'); if (pathAttr) { const org = await this.fetchOrganization(orgDn); const pathValue = org[pathAttr]; entry[pathAttr] = Array.isArray(pathValue) ? pathValue[0] : pathValue; } } // 5. Validate required attributes for (const [attr, def] of Object.entries(schema.properties || {})) { if (def.required === true && !entry[attr]) { throw new Error(`Missing required attribute: ${attr}`); } } // 6. Build DN const mainAttr = resource.mainAttribute; const rawMainValue = entry[mainAttr]; if (!rawMainValue) { throw new Error(`Missing main attribute: ${mainAttr}`); } // Handle array values by taking the first element const mainValue = Array.isArray(rawMainValue) ? String(rawMainValue[0]) : String(rawMainValue); validateDnValue(mainValue, mainAttr); const dn = `${mainAttr}=${escapeDnValue(mainValue)},${resource.base}`; return { dn, entry }; } /** * Get editable attributes from schema (exclude fixed and calculated) */ private getEditableAttributes(schema: BulkImportSchema): string[] { const attrs: string[] = []; for (const [name, def] of Object.entries(schema.properties || {})) { // Exclude fixed if (def.fixed === true) continue; // Exclude organizationLink and organizationPath (calculated auto) if (def.role?.includes('organizationLink')) continue; if (def.role?.includes('organizationPath')) continue; attrs.push(name); } return attrs; } /** * Find attribute by role in schema */ private findAttributeByRole( schema: BulkImportSchema, role: string ): string | null { for (const [name, def] of Object.entries(schema.properties || {})) { if (def.role?.includes(role)) { return name; } } return null; } /** * Fetch organization to get its path */ private async fetchOrganization(dn: string): Promise { const result = await this.server.ldap.search( { paged: false, scope: 'base' }, dn ); if ((result as SearchResult).searchEntries.length === 0) { throw new Error(`Organization not found: ${dn}`); } return (result as SearchResult).searchEntries[0]; } /** * Check if entry exists */ private async entryExists(dn: string): Promise { try { const result = await this.server.ldap.search( { paged: false, scope: 'base' }, dn ); return (result as SearchResult).searchEntries.length > 0; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { return false; } } /** * Provide configuration for config API */ getConfigApiData(): Record { const apiPrefix = this.config.api_prefix || '/api'; const resources: Array<{ name: string; mainAttribute: string; base: string; maxFileSize: number; batchSize: number; endpoints: { template: string; import: string; }; }> = []; this.resources.forEach((resource, resourceName) => { resources.push({ name: resourceName, mainAttribute: resource.mainAttribute, base: resource.base, maxFileSize: parseInt(this.config.bulk_import_max_file_size as string, 10) || 10485760, batchSize: parseInt(this.config.bulk_import_batch_size as string, 10) || 100, endpoints: { template: `${apiPrefix}/v1/ldap/bulk-import/${resourceName}/template.csv`, import: `${apiPrefix}/v1/ldap/bulk-import/${resourceName}`, }, }); }); return { enabled: true, resources, }; } } linagora-ldap-rest-16e557e/src/plugins/ldap/departmentSync.ts000066400000000000000000000173461522642357000243230ustar00rootroot00000000000000/** * @module plugins/ldap/departmentSync * @author Xavier Guimard * * Plugin to maintain consistency of department links when organizations are renamed/moved * Automatically updates twakeDepartmentLink and twakeDepartmentPath attributes on all * resources (users, groups, etc.) when their linked organization DN changes. */ import type { SearchResult } from 'ldapts'; import DmPlugin, { type Role } from '../../abstract/plugin'; import type { DM } from '../../bin'; import type { Hooks } from '../../hooks'; export default class LdapDepartmentSync extends DmPlugin { name = 'ldapDepartmentSync'; roles: Role[] = ['consistency'] as const; private linkAttr: string; private pathAttr: string; constructor(server: DM) { super(server); this.linkAttr = (this.config.ldap_organization_link_attribute as string) || 'twakeDepartmentLink'; this.pathAttr = (this.config.ldap_organization_path_attribute as string) || 'twakeDepartmentPath'; } hooks: Hooks = { /** * After an organization is renamed/moved, update all resources * (users, groups, etc.) that reference the old DN or its descendants * via twakeDepartmentLink */ ldaprenamedone: async ([oldDn, newDn]) => { // Only process organization renames (ou=...) if (!/^ou=/.test(oldDn)) return; this.logger.info( `Organization renamed from ${oldDn} to ${newDn}, synchronizing linked resources...` ); try { const baseDn = (this.config.ldap_top_organization as string).replace( /^ou=[^,]+,/, '' ); // Update resources linked to the renamed organization await this.updateLinkedResources(oldDn, newDn, baseDn); // Update resources linked to sub-organizations (descendants) // When an org is moved, LDAP automatically moves its children, // but their DN changes, so we need to update references await this.updateDescendantReferences(oldDn, newDn, baseDn); } catch (err) { this.logger.error( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Error synchronizing department links after rename: ${err}` ); } }, }; /** * Update resources that are directly linked to the renamed organization */ private async updateLinkedResources( oldDn: string, newDn: string, baseDn: string ): Promise { const filter = `(${this.linkAttr}=${oldDn})`; this.logger.debug( `Searching for resources directly linked to ${oldDn}: ${filter}` ); const results = await this.server.ldap.search( { paged: true, filter, attributes: [this.linkAttr, this.pathAttr], }, baseDn ); let updatedCount = 0; for await (const result of results as AsyncGenerator) { for (const entry of result.searchEntries) { const entryDn = String(entry.dn); try { // Get the new department path from the new organization const newPath = await this.getDepartmentPath(newDn); // Update the entry await this.server.ldap.modify(entryDn, { replace: { [this.linkAttr]: newDn, [this.pathAttr]: newPath, }, }); updatedCount++; this.logger.debug( `Updated ${entryDn}: ${this.linkAttr}=${newDn}, ${this.pathAttr}=${newPath}` ); } catch (err) { this.logger.error( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Failed to update ${entryDn} after organization rename: ${err}` ); } } } this.logger.info( `Updated ${updatedCount} resources directly linked to the renamed organization` ); } /** * Update resources linked to sub-organizations that were moved * When ou=IT moves from ou=IT,ou=Departments to ou=IT,ou=Tech, * its child ou=Dev,ou=IT,ou=Departments becomes ou=Dev,ou=IT,ou=Tech * We need to update resources pointing to the old child DN */ private async updateDescendantReferences( oldParentDn: string, newParentDn: string, baseDn: string ): Promise { // Find all resources that have the linkAttr attribute // We'll filter for descendants in code since LDAP wildcards don't work well here const filter = `(${this.linkAttr}=*)`; this.logger.debug( `Searching for resources linked to descendants of ${oldParentDn}: ${filter}` ); const results = await this.server.ldap.search( { paged: true, filter, attributes: [this.linkAttr, this.pathAttr], }, baseDn ); let updatedCount = 0; for await (const result of results as AsyncGenerator) { for (const entry of result.searchEntries) { const entryDn = String(entry.dn); const oldLink = entry[this.linkAttr]; const oldLinkStr = Array.isArray(oldLink) ? String(oldLink[0]) : String(oldLink); // Skip if this is the direct link (already handled above) if (oldLinkStr === oldParentDn) continue; // Only process if this link is a descendant of the renamed organization if (!oldLinkStr.endsWith(`,${oldParentDn}`)) continue; try { // Replace the old parent DN with the new parent DN in the link // Example: "ou=Dev,ou=IT,ou=Departments" -> "ou=Dev,ou=IT,ou=Tech" const newLink = oldLinkStr.replace( `,${oldParentDn}`, `,${newParentDn}` ); // Get the new department path from the new organization const newPath = await this.getDepartmentPath(newLink); // Update the entry await this.server.ldap.modify(entryDn, { replace: { [this.linkAttr]: newLink, [this.pathAttr]: newPath, }, }); updatedCount++; this.logger.debug( `Updated descendant link ${entryDn}: ${this.linkAttr}=${newLink}, ${this.pathAttr}=${newPath}` ); } catch (err) { this.logger.error( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Failed to update descendant reference ${entryDn}: ${err}` ); } } } this.logger.info( `Updated ${updatedCount} resources linked to descendants of the renamed organization` ); } /** * Get department path from an organization DN * Fetches the path attribute directly from the organization entry */ private async getDepartmentPath(orgDn: string): Promise { try { const result = (await this.server.ldap.search( { paged: false, scope: 'base', attributes: [this.pathAttr, 'ou', 'o'] }, orgDn )) as SearchResult; if (!result.searchEntries || result.searchEntries.length === 0) { throw new Error(`Organization ${orgDn} not found`); } const org = result.searchEntries[0]; // Return the path attribute if it exists if (org[this.pathAttr]) { const path = org[this.pathAttr]; return Array.isArray(path) ? String(path[0]) : String(path); } // Fallback: construct path from ou or o attribute const ou = org.ou || org.o; if (ou) { const name = Array.isArray(ou) ? String(ou[0]) : String(ou); return `/${name}`; } // Last resort: use the DN this.logger.warn( `Organization ${orgDn} has no ${this.pathAttr} attribute, using DN` ); return orgDn; } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Failed to fetch organization ${orgDn}: ${err}`); } } } linagora-ldap-rest-16e557e/src/plugins/ldap/externalUsersInGroups.ts000066400000000000000000000062761522642357000256560ustar00rootroot00000000000000/** * @module plugins/ldap/externalUsersInGroups * @author Xavier Guimard * * Creates on-the-fly missing group users into a branch * This permits to add external users into mailing lists */ import DmPlugin, { type Role } from '../../abstract/plugin'; import type { DM } from '../../bin'; import { Hooks } from '../../hooks'; import ldapActions, { AttributesList } from '../../lib/ldapActions'; import { launchHooks, launchHooksChained } from '../../lib/utils'; export default class ExternalUsersInGroups extends DmPlugin { name = 'externalUsersInGroups'; roles: Role[] = ['consistency'] as const; dependencies = { ldapGroups: 'core/ldap/groups' }; ldap: ldapActions; constructor(server: DM) { super(server); this.ldap = server.ldap; } hooks: Hooks = { ldapgroupvalidatemembers: async ([dn, members]) => { // Parallelize member validation/creation with global concurrency limit await Promise.all( members .map(m => m.replace(/\s/g, '')) .map(m => this.server.ldap.queryLimit(async () => { if ( !new RegExp( `mail=([^,]+),${this.config.external_members_branch}$` ).test(m) ) return false; try { // Check if member exists (will use cache with scope: 'base') await this.ldap.search({ paged: false, scope: 'base' }, m); return true; } catch { // Member doesn't exist, create it const mail = m.replace(/^mail=([^,]+),.*$/, '$1'); if (!mail) throw new Error(`Malformed member ${m}`); // Check if mail domain is in managed domains if ( this.config.mail_domain && Array.isArray(this.config.mail_domain) ) { const domain = mail.split('@')[1]; if (domain && this.config.mail_domain.includes(domain)) { throw new Error( `Cannot create external user with managed domain: ${mail}` ); } } let entry: AttributesList = { objectClass: (this.config.external_branch_class || this.config.user_class) as string[], mail, [this.config.ldap_groups_main_attribute as string]: mail, sn: mail, }; [m, entry] = await launchHooksChained( this.registeredHooks.externaluserentry, [m, entry] ); try { await this.ldap.add(m, entry); void launchHooks( this.registeredHooks.externaluseradded, m, entry ); return true; } catch (e) { throw new Error( `Unable to insert ${m}: ${e instanceof Error ? e.message : String(e)}` ); } } }) ) ); return [dn, members]; }, }; } linagora-ldap-rest-16e557e/src/plugins/ldap/flatGeneric.ts000066400000000000000000000130601522642357000235330ustar00rootroot00000000000000/** * @module plugins/ldap/flatGeneric * @author Xavier Guimard * * Generic plugin to manage LDAP flat entities from schema files * Automatically creates sub-plugins based on schema metadata */ import fs from 'fs'; import type { Express } from 'express'; import DmPlugin, { type Role } from '../../abstract/plugin'; import LdapFlat from '../../abstract/ldapFlat'; import type { DM } from '../../bin'; import { transformSchemas } from '../../lib/utils'; import type { Schema } from '../../config/schema'; import type { AttributesList } from '../../lib/ldapActions'; interface EnrichedSchema extends Schema { entity: { name: string; mainAttribute: string; objectClass: string[]; singularName: string; pluralName: string; base: string; defaultAttributes?: Record; }; } /** * Concrete implementation of LdapFlat for generic instances */ class LdapFlatInstance extends LdapFlat { name: string = 'ldapFlatInstance'; roles: Role[] = ['api'] as const; // Ensure department sync is loaded to maintain consistency // when organizations are renamed/moved dependencies = { departmentSync: 'core/ldap/departmentSync', }; constructor(server: DM, config: ConstructorParameters[1]) { super(server, config); } } export default class LdapFlatGeneric extends DmPlugin { name = 'ldapFlatGeneric'; roles: Role[] = ['configurable'] as const; instances: LdapFlatInstance[] = []; private schemaPaths: string[] = []; constructor(server: DM) { super(server); const schemas = this.config.ldap_flat_schema || []; if (schemas.length === 0) { this.logger.warn('No schemas provided for ldapFlatGeneric plugin'); return; } // Load each schema and create an instance schemas.forEach(schemaPath => { try { const schemaData = fs.readFileSync(schemaPath, 'utf8'); const schema = JSON.parse( transformSchemas(schemaData, this.config) ) as EnrichedSchema; if (!schema.entity) { throw new Error( `Schema ${schemaPath} is missing "entity" metadata section` ); } // Validate required fields const required = [ 'name', 'mainAttribute', 'objectClass', 'singularName', 'pluralName', 'base', ]; for (const field of required) { if (!schema.entity[field as keyof typeof schema.entity]) { throw new Error(`Schema ${schemaPath} is missing entity.${field}`); } } // Resolve base with config placeholders let base = schema.entity.base; // Replace all {config_key} patterns with actual config values base = base.replace(/\{([^}]+)\}/g, (match, key) => { const configKey = key as keyof typeof this.config; const value = this.config[configKey]; return typeof value === 'string' ? value : match; }); // Create the instance const instance = new LdapFlatInstance(server, { base, mainAttribute: schema.entity.mainAttribute, objectClass: schema.entity.objectClass, defaultAttributes: (schema.entity.defaultAttributes || {}) as AttributesList, schemaPath, singularName: schema.entity.singularName, pluralName: schema.entity.pluralName, hookPrefix: `ldap${schema.entity.name}`, }); instance.name = `ldapFlat:${schema.entity.name}`; this.instances.push(instance); this.schemaPaths.push(schemaPath); this.logger.info( `Created ldapFlat instance for "${schema.entity.name}" (${schema.entity.pluralName})` ); } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions this.logger.error(`Failed to load schema ${schemaPath}: ${err}`); } }); } /** * Register all instance APIs */ api(app: Express): void { this.instances.forEach(instance => { instance.api(app); }); } /** * Provide configuration for config API */ getConfigApiData(): Record { const apiPrefix = this.config.api_prefix || '/api'; const staticName = this.config.static_name || 'static'; const flatResources = this.instances.map((instance, index) => { // Generate schema URL if static plugin is loaded let schemaUrl: string | undefined; if (this.server.loadedPlugins['static'] && this.schemaPaths[index]) { const schemaPath = this.schemaPaths[index]; const schemasIndex = schemaPath.indexOf('/schemas/'); if (schemasIndex !== -1) { const relativePath = schemaPath.substring(schemasIndex); schemaUrl = `/${staticName}${relativePath}`; } } return { name: instance.name.replace('ldapFlat:', ''), singularName: instance.singularName, pluralName: instance.pluralName, mainAttribute: instance.mainAttribute, objectClass: instance.objectClass, base: instance.base, schema: instance.schema || { strict: false, attributes: {} }, schemaUrl, endpoints: { list: `${apiPrefix}/v1/ldap/${instance.pluralName}`, get: `${apiPrefix}/v1/ldap/${instance.pluralName}/:id`, create: `${apiPrefix}/v1/ldap/${instance.pluralName}`, update: `${apiPrefix}/v1/ldap/${instance.pluralName}/:id`, delete: `${apiPrefix}/v1/ldap/${instance.pluralName}/:id`, }, }; }); return { flatResources, }; } } linagora-ldap-rest-16e557e/src/plugins/ldap/groups.ts000066400000000000000000001122321522642357000226300ustar00rootroot00000000000000/** * @module plugins/ldap/groups * @author Xavier Guimard * * Plugin to manage groups into LDAP server * - add/delete groups * - add/delete members of groups * - detect user deletion to remove them from groups (hook) */ import fs from 'fs'; import type { Express, Request, Response } from 'express'; import DmPlugin, { type Role } from '../../abstract/plugin'; import type { DM } from '../../bin'; import type { Hooks } from '../../hooks'; import type ldapActions from '../../lib/ldapActions'; import type { AttributesList, AttributeValue, LdapList, ModifyRequest, SearchResult, } from '../../lib/ldapActions'; import { jsonBody, ok, tryMethod, wantJson, } from '../../lib/expressFormatedResponses'; import { asyncHandler, escapeDnValue, escapeLdapFilter, escapeRegex, getCompiledRegex, launchHooks, launchHooksChained, transformSchemas, validateDnValue, } from '../../lib/utils'; import { BadRequestError, NotFoundError } from '../../lib/errors'; import type { Schema } from '../../config/schema'; export interface postAdd { cn?: string; [key: string]: AttributeValue | undefined; } export type postModify = ModifyRequest; /** * Shared OpenAPI schemas surfaced by this plugin. Picked up by * scripts/generate-openapi.ts and merged into `components.schemas`. * * @openapi-component * Group: * type: object * description: An LDAP group entry (groupOfNames / groupOfUniqueNames). * required: [dn, cn] * properties: * dn: * type: string * description: Fully-qualified distinguished name of the entry. * example: cn=admins,ou=groups,dc=example,dc=com * cn: * type: string * description: Common name (RDN attribute) of the group. * example: admins * description: * type: string * example: Server administrators * member: * type: array * items: { type: string } * description: DNs of the group's members. * example: * - uid=alice,ou=users,dc=example,dc=com * - uid=bob,ou=users,dc=example,dc=com * GroupCreate: * type: object * required: [cn] * properties: * cn: * type: string * description: Common name of the new group. * description: * type: string * member: * type: array * items: { type: string } * description: Initial members (DNs). * example: * cn: admins * description: Server administrators * member: * - uid=alice,ou=users,dc=example,dc=com * GroupModify: * type: object * description: | * Partial update. Each provided attribute is replaced wholesale. Use * the dedicated `/members` endpoints to add/remove members one at a * time without supplying the full list. * properties: * description: { type: string } * member: * type: array * items: { type: string } * example: * description: Updated description * Error: * type: object * properties: * error: { type: string } * code: { type: integer } * example: * error: Group not found * code: 404 */ export default class LdapGroups extends DmPlugin { name = 'ldapGroups'; roles: Role[] = ['api', 'configurable'] as const; base?: string; ldap: ldapActions; cn: string; schema?: Schema; constructor(server: DM) { super(server); this.ldap = server.ldap; this.base = this.config.ldap_group_base as string; this.cn = this.config.ldap_groups_main_attribute as string; if (!this.base) { this.base = this.config.ldap_base; this.logger.warn(`LDAP group base is not defined, using "${this.base}"`); } if (!this.base) { throw new Error('LDAP base is not defined, please set --ldap-group-base'); } if (this.config.group_schema) { fs.readFile(this.config.group_schema, (err, data) => { if (err) { this.logger.error( `Failed to load group schema from ${this.config.group_schema}: ${err}` ); } else { try { this.schema = JSON.parse( transformSchemas(data.toString(), this.config) ) as Schema; this.logger.debug('Group schema loaded'); } catch (e) { this.logger.error( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Failed to parse ${this.config.schemas_path}/group.json: ${e}` ); } } }); } } /** * Catch all deletion to remove deleted users from groups */ hooks: Hooks = { ldapdeleterequest: async ([dn, req]: [string | string[], Request?]) => { let _dn = dn; if (!Array.isArray(_dn)) { _dn = [_dn]; } this.logger.debug( `User deletion detected, removing from groups: ${_dn.join(', ')}` ); // Remove user from groups before actual deletion await Promise.all( _dn.map(dnEntry => this.deleteMemberFromAll(dnEntry)) ).catch(err => { this.logger.error('Failed to process user deletion in groups:', err); }); return [dn, req] as [string | string[], Request?]; }, }; /** * API routes */ api(app: Express): void { /** * @openapi * summary: List groups * description: | * Returns every group under the configured group base. The optional * `match` query supports either a raw LDAP filter (when it contains * `=`) or a simple value matched against the RDN attribute. * parameters: * - in: query * name: match * schema: { type: string } * description: LDAP filter or simple value. * example: admin* * - in: query * name: attributes * schema: { type: string } * description: Comma-separated list of attributes to return. * example: cn,description,member * responses: * '200': * description: Group list. * content: * application/json: * schema: * type: array * items: { $ref: '#/components/schemas/Group' } * example: * - dn: cn=admins,ou=groups,dc=example,dc=com * cn: admins * description: Server administrators * member: * - uid=alice,ou=users,dc=example,dc=com * - dn: cn=editors,ou=groups,dc=example,dc=com * cn: editors * description: Content editors * member: [] * '400': * description: Invalid `match` query. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // List groups app.get( `${this.config.api_prefix}/v1/ldap/groups`, asyncHandler(async (req, res) => { if (!wantJson(req, res)) return; const args: { filter?: string; attributes?: string[] } = {}; if (req.query.match) { if (typeof req.query.match !== 'string') { throw new BadRequestError('Invalid match query'); } if (/=/.test(req.query.match)) { // Custom filter syntax - validate strictly to prevent LDAP injection // Only allow alphanumeric, wildcards, and LDAP filter syntax chars if (!/^[\w*=()&|, -]+$/.test(req.query.match)) { throw new BadRequestError( 'Invalid match query: contains forbidden characters' ); } // Use as-is (backward compatibility for advanced queries) args.filter = req.query.match; } else { // Simple value - escape to prevent LDAP injection const escapedMatch = escapeLdapFilter(req.query.match); args.filter = `(${this.cn}=${escapedMatch})`; } } if (req.query.attributes && typeof req.query.attributes === 'string') args.attributes = req.query.attributes.split(','); const list = await this.listGroups(args); return ok(res, list); }) ); /** * @openapi * summary: Get group by CN * description: | * The `:cn` segment may be either the group's RDN value (e.g. * `admins`) 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 * member: * - uid=alice,ou=users,dc=example,dc=com * '404': * description: Group not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Get group by cn or DN app.get( `${this.config.api_prefix}/v1/ldap/groups/:cn`, asyncHandler(async (req, res) => this.apiGet(req, res)) ); /** * @openapi * summary: Create group * requestBody: * required: true * content: * application/json: * schema: { $ref: '#/components/schemas/GroupCreate' } * responses: * '200': * description: Group created. * content: * application/json: * example: { success: true } * '400': * description: Validation error. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Add group app.post( `${this.config.api_prefix}/v1/ldap/groups`, asyncHandler(async (req, res) => this.apiAdd(req, res, this.cn)) ); /** * @openapi * summary: Delete group * responses: * '200': * description: Group deleted. * content: * application/json: * example: { success: true } * '404': * description: Group not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Delete group app.delete( `${this.config.api_prefix}/v1/ldap/groups/:cn`, asyncHandler(async (req, res) => this.apiDelete(req, res)) ); /** * @openapi * summary: Modify group * requestBody: * required: true * content: * application/json: * schema: { $ref: '#/components/schemas/GroupModify' } * responses: * '200': * description: Group updated. * content: * application/json: * example: { success: true } * '404': * description: Group not found. */ // Modify group app.put( `${this.config.api_prefix}/v1/ldap/groups/:cn`, asyncHandler(async (req, res) => this.apiModify(req, res)) ); /** * @openapi * summary: Add members to group * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [member] * properties: * member: * oneOf: * - type: string * - type: array * items: { type: string } * description: One DN or a list of DNs to add. * example: * member: * - uid=carol,ou=users,dc=example,dc=com * - uid=dave,ou=users,dc=example,dc=com * responses: * '200': * description: Members added. * content: * application/json: * example: { success: true } */ // Add member to group app.post( `${this.config.api_prefix}/v1/ldap/groups/:cn/members`, asyncHandler(async (req, res) => { const cn = decodeURIComponent(req.params.cn as string); if (!cn) throw new BadRequestError('cn is required'); const body = jsonBody(req, res, 'member') as { member: string | string[]; }; if (!body) return; await tryMethod(res, this.addMember.bind(this), cn, body.member); }) ); /** * @openapi * summary: Remove member from group * description: | * `:member` is the URL-encoded DN of the member to remove. * responses: * '200': * description: Member removed. * content: * application/json: * example: { success: true } * '404': * description: Group or member not found. */ // Delete member from group app.delete( `${this.config.api_prefix}/v1/ldap/groups/:cn/members/:member`, asyncHandler(async (req, res) => { if (!wantJson(req, res)) return; const cn = decodeURIComponent(req.params.cn as string); const member = decodeURIComponent(req.params.member as string); if (!cn || !member) { throw new BadRequestError('cn and member are required'); } await tryMethod(res, this.deleteMember.bind(this), cn, member); }) ); // Move group to different organization (only if organization attributes are configured) if ( this.config.ldap_organization_link_attribute && this.config.ldap_organization_path_attribute ) { /** * @openapi * summary: Move group to another organization * description: | * Only registered when both * `--ldap-organization-link-attribute` and * `--ldap-organization-path-attribute` are configured. * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [targetOrgDn] * properties: * targetOrgDn: * type: string * description: DN of the destination organization. * example: * targetOrgDn: ou=engineering,ou=organizations,dc=example,dc=com * responses: * '200': * description: Group moved. * content: * application/json: * example: { success: true } */ app.post( `${this.config.api_prefix}/v1/ldap/groups/:cn/move`, asyncHandler(async (req, res) => { if (!wantJson(req, res)) return; const cn = decodeURIComponent(req.params.cn as string); if (!cn) throw new BadRequestError('cn is required'); const body = jsonBody(req, res, 'targetOrgDn') as { targetOrgDn: string; }; if (!body) return; await tryMethod( res, this.moveGroup.bind(this), cn, body.targetOrgDn, req ); }) ); } /** * @openapi * summary: Rename group (change CN) * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [newCn] * properties: * newCn: * type: string * description: New CN value (RDN attribute). * example: * newCn: site-admins * responses: * '200': * description: Group renamed. * content: * application/json: * example: { success: true } */ // Rename group (change cn) app.post( `${this.config.api_prefix}/v1/ldap/groups/:cn/rename`, asyncHandler(async (req, res) => { if (!wantJson(req, res)) return; const cn = decodeURIComponent(req.params.cn as string); if (!cn) throw new BadRequestError('cn is required'); const body = jsonBody(req, res, 'newCn') as { newCn: string; }; if (!body) return; await tryMethod(res, this.renameGroup.bind(this), cn, body.newCn); }) ); } async apiGet(req: Request, res: Response): Promise { if (!wantJson(req, res)) return; const cn = decodeURIComponent(req.params.cn as string); try { const dn = /,/.test(cn) ? cn : `${this.cn}=${escapeDnValue(cn)},${this.base}`; const result = (await this.ldap.search( { paged: false, scope: 'base' }, dn )) as SearchResult; if (result.searchEntries.length === 0) { throw new NotFoundError('Group not found'); } res.json(result.searchEntries[0]); } catch (err) { // LDAP NoSuchObjectError (code 32) means not found if ( (err as { code?: number }).code && (err as { code?: number }).code === 32 ) { throw new NotFoundError('Group not found'); } throw err; } } async apiAdd( req: Request, res: Response, ...requiredFields: string[] ): Promise { const body = jsonBody(req, res, ...requiredFields) as postAdd | false; if (!body) return; const cn = body[this.cn]; const members = body.member ? body.member : []; const additional: AttributesList = Object.fromEntries( Object.entries(body).filter( ([key, _value]) => key !== this.cn && key !== 'member' && _value !== undefined && // Filter out fixed fields from schema !(this.schema?.attributes[key]?.fixed === true) ) ) as AttributesList; await tryMethod(res, this.addGroup.bind(this), cn, members, additional); } async apiDelete(req: Request, res: Response): Promise { if (!wantJson(req, res)) return; const cn = decodeURIComponent(req.params.cn as string); if (!cn) throw new BadRequestError('cn is required'); await tryMethod(res, this.deleteGroup.bind(this), cn); } async apiModify(req: Request, res: Response): Promise { const body = jsonBody(req, res) as postModify | false; if (!body) return; const dn = this.fixDn(decodeURIComponent(req.params.cn as string)); if (!dn) throw new BadRequestError('cn is required'); // Filter out fixed fields from schema const filteredBody = Object.fromEntries( Object.entries(body).filter( ([key, _value]) => !(this.schema?.attributes[key]?.fixed === true) ) ) as postModify; await tryMethod(res, this.modifyGroup.bind(this), dn, filteredBody); } /** * LDAP group methods */ async addGroup( cn: string, members: string[] = [], additional: AttributesList = {} ): Promise { let dn: string; if (new RegExp(`^${this.cn}=`).test(cn)) { dn = cn; // Extract the RDN value, correctly handling escaped commas cn = cn.replace( new RegExp(`^${this.cn}=((?:\\\\.|[^,])+)(?:,.*)?$`), '$1' ); } else { validateDnValue(cn, this.cn); dn = `${this.cn}=${escapeDnValue(cn)},${this.base}`; } await this.validateMembers(dn, members); await this.validateNewGroup(dn, { objectClass: this.config.group_class as string[], cn, member: members, ...additional, }); // Build entry const objectClasses = [...(this.config.group_class as string[])]; // Add twakeGroup objectClass if group has mail or twake-specific attributes // Only add if not already using twakeStaticGroup (which includes all these attributes) if ( (additional.mail || additional.twakeMailboxType || additional.twakeDepartmentLink || additional.twakeDepartmentPath) && !objectClasses.includes('twakeGroup') && !objectClasses.includes('twakeStaticGroup') ) { objectClasses.push('twakeGroup'); } let entry: AttributesList = { // Classes from --group-class (with twakeGroup added if mail present) objectClass: objectClasses, // Default attributes from --group-default-attributes ...this.config.group_default_attributes, // cn calculated here [this.cn]: cn, // members with at least one fake member to satisfy LDAP groupOfNames schema member: [this.config.group_dummy_user as string], ...additional, }; if (members.length) (entry.member as string[]).push(...members); [dn, entry] = await launchHooksChained(this.registeredHooks.ldapgroupadd, [ dn, entry, ]); let res; try { res = await this.ldap.add(dn, entry); } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Failed to add group ${dn}: ${err}`); } void launchHooks(this.registeredHooks.ldapgroupadddone, [dn, entry]); return res; } async modifyGroup( cn: string, changes: { add?: AttributesList; replace?: AttributesList; delete?: string[] | AttributesList; } ): Promise { let dn = /,/.test(cn) ? cn : `${this.cn}=${escapeDnValue(cn)},${this.base}`; const op = this.opNumber(); [dn, changes] = await launchHooksChained( this.registeredHooks.ldapgroupmodify, [dn, changes, op] ); if (changes.add) { if (changes.add.member) throw new Error('Use dedicated API to add members'); if (changes.add[this.cn]) throw new Error(`${this.cn} attribute iq unique, cannot add`); } if (changes.delete) { if (changes.delete instanceof Object) { if ((changes.delete as AttributesList).member) throw new Error('Use dedicated API to delete members'); if ((changes.delete as AttributesList)[this.cn]) throw new Error(`Cannot delete ${this.cn} attribute`); } if (Array.isArray(changes.delete)) { if (changes.delete.includes('member')) throw new Error('Use dedicated API to delete members'); if (changes.delete.includes(this.cn)) throw new Error(`Cannot delete ${this.cn} attribute`); } } if (changes.replace) { if (changes.replace.member) throw new Error('Use dedicated API to replace members'); if (changes.replace[this.cn]) throw new Error(`Use dedicated API to change ${this.cn} attribute`); } await this.validateChanges(dn, changes); const res = await this.ldap.modify(dn, changes); void launchHooks(this.registeredHooks.ldapgroupmodifydone, [ dn, changes, op, ]); return res; } async renameGroup(cn: string, newCn: string): Promise { if (!/,/.test(cn)) { validateDnValue(cn, this.cn); } if (!/,/.test(newCn)) { validateDnValue(newCn, this.cn); } let dn = /,/.test(cn) ? cn : `${this.cn}=${escapeDnValue(cn)},${this.base}`; let newDn = /,/.test(newCn) ? newCn : `${this.cn}=${escapeDnValue(newCn)},${this.base}`; [dn, newDn] = await launchHooksChained( this.registeredHooks.ldapgrouprename, [dn, newDn] ); const res = await this.ldap.rename(dn, newDn); void launchHooks(this.registeredHooks.ldapgrouprenamedone, [dn, newDn]); return res; } async deleteGroup(cn: string): Promise { let dn = /,/.test(cn) ? cn : `${this.cn}=${escapeDnValue(cn)},${this.base}`; dn = await launchHooksChained(this.registeredHooks.ldapgroupdelete, dn); const res = await this.ldap.delete(dn); void launchHooks(this.registeredHooks.ldapgroupdeletedone, dn); return res; } async addMember(cn: string, member: string | string[]): Promise { const dn = /,/.test(cn) ? cn : `${this.cn}=${escapeDnValue(cn)},${this.base}`; if (!Array.isArray(member)) member = [member]; [cn, member] = await launchHooksChained( this.registeredHooks.ldapgroupaddmember, [cn, member] ); await this.validateMembers(dn, member); return await this.ldap .modify(dn, { add: { member }, }) .catch(err => { throw new Error(`Failed to add member(s) to ${dn}: ${err}`); }); } async deleteMember(cn: string, member: string): Promise { const dn = /,/.test(cn) ? cn : `${this.cn}=${escapeDnValue(cn)},${this.base}`; [cn, member] = await launchHooksChained( this.registeredHooks.ldapgroupdeletemember, [cn, member] ); if (member === this.config.group_dummy_user) throw new Error('Cannot delete dummy member from group'); return await this.ldap .modify(dn, { delete: { member: member }, }) .catch(err => { throw new Error(`Failed to delete member ${member} from ${dn}: ${err}`); }); } async deleteMemberFromAll(memberDn: string): Promise { const res = (await this.ldap .search( { filter: `member=${memberDn}`, paged: false, attributes: [this.cn], }, this.base as string ) .catch(err => { throw new Error(`Failed to search groups from ${this.base}: ${err}`); })) as SearchResult; await Promise.all( res.searchEntries.map(entry => this.ldap .modify(entry.dn, { delete: { member: memberDn }, }) .catch(err => this.logger.error( `Failed to remove ${memberDn} from group ${entry.dn}:`, err ) ) ) ); } /** * Move a group to a different organization by updating organization link and path attributes * This method should only be called when organization attributes are configured * @param cn Group cn or DN * @param targetOrgDn DN of the target organization * @returns Success status */ async moveGroup( cn: string, targetOrgDn: string, req?: Request ): Promise<{ success: boolean }> { const linkAttr = this.config.ldap_organization_link_attribute as string; const pathAttr = this.config.ldap_organization_path_attribute as string; const dn = /,/.test(cn) ? cn : `${this.cn}=${escapeDnValue(cn)},${this.base}`; // Get current group to check if it has department attributes const currentGroup = (await this.ldap.search( { paged: false, scope: 'base' }, dn )) as SearchResult; if (currentGroup.searchEntries.length === 0) { throw new Error(`Group ${dn} not found`); } const group = currentGroup.searchEntries[0]; const currentDeptLink = group[linkAttr] as string | undefined; // Check if group has department link attribute if (!currentDeptLink) { throw new Error( `Group ${dn} does not have ${linkAttr} attribute and cannot be moved` ); } // Prevent moving to the same location if (currentDeptLink === targetOrgDn) { throw new Error('Group is already in the target organization'); } // Verify target organization exists and get its path let targetOrg: SearchResult; try { targetOrg = (await this.ldap.search( { paged: false, scope: 'base', attributes: [pathAttr, 'ou', 'o'], }, targetOrgDn )) as SearchResult; } catch (err) { throw new Error( `Target organization ${targetOrgDn} not found: ${err instanceof Error && err.message ? err.message : String(err)}` ); } if (targetOrg.searchEntries.length === 0) { throw new Error(`Target organization ${targetOrgDn} not found`); } const targetPath = targetOrg.searchEntries[0][pathAttr] as | string | undefined; if (!targetPath) { throw new Error( `Target organization ${targetOrgDn} does not have ${pathAttr} attribute` ); } // Update group's department link and path await this.ldap.modify( dn, { replace: { [linkAttr]: targetOrgDn, [pathAttr]: targetPath, }, }, req ); this.logger.info( `Group ${dn} moved from ${currentDeptLink} to ${targetOrgDn}` ); return { success: true }; } /** * List groups from LDAP * @param param0 {filter, attributes}: * - filter: LDAP filter (default '(objectClass=*)') * - attributes: attributes to fetch (default ['cn','member']) * @returns LdapList (Record) */ async listGroups({ filter, attributes, }: { filter?: string; attributes?: string[]; } = {}): Promise { const _res: AsyncGenerator = (await this.ldap .search( { filter: filter || '(objectClass=*)', attributes: attributes || [this.cn, 'member'], paged: true, }, this.base as string ) .catch(err => { throw new Error(`Failed to list groups from ${this.base}: ${err}`); })) as AsyncGenerator; let entries: LdapList = {}; for await (const r of _res) { r.searchEntries.map(entry => { const s = entry[this.cn] as string; if (s) entries[s] = entry; if (!Array.isArray(entries[s].member)) entries[s].member = [entries[s].member as string]; entries[s].member = (entries[s].member as string[]).filter( (m: string) => { return m !== this.config.group_dummy_user; } ); }); } entries = await launchHooksChained( this.registeredHooks._ldapgrouplist, entries ); return entries; } /** * Simple formatter for listGroups() * @param cn main attribute value (partial or full) * @param partial boolean, true for partial search (default false) * @param attributes array of attributes to return * @returns LdapList (means Record where key is the --ldap-user-main-attribute value [default: cn]) */ async searchGroupsByName( cn: string, partial = false, attributes: string[] = [this.cn, 'member'] ): Promise { const filter = partial ? `(${this.cn}=*${cn}*)` : `(${this.cn}=${cn})`; return await this.listGroups({ filter, attributes }); } protected fixDn(dn: string): string | false { if (!dn) return false; return /,/.test(dn) ? dn : `${this.cn}=${escapeDnValue(dn)},${this.base}`; } /** * Verify that each member exists in LDAP * * It calls ldapgroupvalidatemembers hook before validation * so that plugins can modify the members list and/or the group DN * and/or create missing members on the fly * @param dn Group DN (given to hooks) * @param members Array of member DNs to check * @returns nothing, throw if error */ async validateMembers(dn: string, members: string[]): Promise { [dn, members] = await launchHooksChained( this.server.hooks.ldapgroupvalidatemembers, [dn, members] ); if (this.config.groups_allow_unexistent_members) return; if (!members || !members.length) return; try { // Parallelize member validation with global concurrency limit await Promise.all( members.map(m => this.server.ldap.queryLimit(async () => { try { await this.ldap.search({ paged: false, scope: 'base' }, m); } catch (e) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Member ${m} not found: ${e}`); } }) ) ); } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Failed to find member(s): ${err}`); } } async validateNewGroup(dn: string, entry: AttributesList): Promise { if (!this.schema) return true; [dn, entry] = await launchHooksChained( this.server.hooks.ldapgroupvalidatenew, [dn, entry] ); // Check each field for (const [field, value] of Object.entries(entry)) { if (!(await this._validateOneChange(field, value))) { throw new Error(`Invalid value for field ${field}`); } } // Check required fields for (const [field, test] of Object.entries(this.schema.attributes)) { if (test.required && entry[field] == undefined) throw new Error(`Missing required field ${field}`); } return true; } async validateChanges(dn: string, changes: ModifyRequest): Promise { if (!this.schema) return true; [dn, changes] = await launchHooksChained( this.server.hooks.ldapgroupvalidatechanges, [dn, changes] ); if (changes.add) { for (const [field, value] of Object.entries(changes.add)) { await this._validateOneChange(field, value); } } if (changes.replace) { for (const [field, value] of Object.entries(changes.replace)) { await this._validateOneChange(field, value); } } if (changes.delete && changes.delete instanceof Object) { for (const v of Array.isArray(changes.delete) ? changes.delete : Object.keys(changes.delete)) { await this._validateOneChange(v, null); } } return true; } async _validateOneChange( field: string, value: AttributeValue | null ): Promise { if (!this.schema) return true; const test = this.schema.attributes[field]; if (!test) { if (this.schema.strict) throw new Error(`Field ${field} is not allowed`); return true; } if (value === null || value === undefined) { if (test.required) throw new Error(`Field ${field} is required`); return true; } if (test.type === 'array') { if (!Array.isArray(value)) throw new Error(`Field ${field} must be an array`); if (!test.items) throw new Error(`Schema error: no item for array ${field}`); if (test.items.type === 'array') throw new Error( `Schema error: array of array not supported for ${field}` ); if (test.items.test) { const itemRegex = typeof test.items.test === 'string' ? getCompiledRegex(test.items.test) : test.items.test; for (let v of value) { if (typeof v !== test.items.type) throw new Error( `Field ${field} must be of type ${test.items.type}` ); if (typeof v !== 'string') v = v.toString(); if (!itemRegex.test(v)) throw new Error(`Field ${field} has invalid value ${v}`); } } } else if (test.type === 'pointer') { if (typeof value !== 'string') throw new Error(`Field ${field} must be a string (DN pointer)`); const dnValue: string = value; // Check branch restriction if provided if (test.branch && test.branch.length > 0) { const isInBranch = test.branch.some(branch => { const branchPattern = getCompiledRegex( `,?${escapeRegex(branch)}$`, 'i' ); return branchPattern.test(dnValue); }); if (!isInBranch) { throw new Error( `Field ${field} must point to a DN within allowed branches: ${test.branch.join(', ')}` ); } } // Verify that the DN exists in LDAP (will use cache) try { const result = (await this.ldap.search( { paged: false, scope: 'base' }, dnValue )) as SearchResult; if ( !result || !result.searchEntries || result.searchEntries.length === 0 ) throw new Error( `Field ${field} points to non-existent DN: ${dnValue}` ); } catch (err) { throw new Error( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Field ${field} points to invalid or non-existent DN: ${dnValue}: ${err}` ); } // Also check test regex if provided if (test.test) { const testRegex = typeof test.test === 'string' ? getCompiledRegex(test.test) : test.test; if (!testRegex.test(dnValue)) throw new Error(`Field ${field} has invalid value ${dnValue}`); } } else { if (typeof value !== test.type) return false; if (typeof value !== 'string') value = value.toString(); if (test.test) { const testRegex = typeof test.test === 'string' ? getCompiledRegex(test.test) : test.test; if (!testRegex.test(value)) throw new Error(`Field ${field} has invalid value ${value}`); } } return true; } /** * Provide configuration for config API */ getConfigApiData(): Record { const apiPrefix = this.config.api_prefix || '/api'; // Generate schema URL if static plugin is loaded let schemaUrl: string | undefined; if (this.server.loadedPlugins['static'] && this.config.group_schema) { const staticName = this.config.static_name || 'static'; const schemasIndex = this.config.group_schema.indexOf('/schemas/'); if (schemasIndex !== -1) { const relativePath = this.config.group_schema.substring(schemasIndex); schemaUrl = `/${staticName}${relativePath}`; } } return { enabled: true, base: this.base || '', mainAttribute: this.cn || 'cn', objectClass: this.config.group_class || ['top', 'groupOfNames'], schema: this.schema, schemaUrl, endpoints: { list: `${apiPrefix}/v1/ldap/groups`, get: `${apiPrefix}/v1/ldap/groups/:id`, create: `${apiPrefix}/v1/ldap/groups`, update: `${apiPrefix}/v1/ldap/groups/:id`, delete: `${apiPrefix}/v1/ldap/groups/:id`, addMember: `${apiPrefix}/v1/ldap/groups/:id/members`, removeMember: `${apiPrefix}/v1/ldap/groups/:id/members/:memberId`, rename: `${apiPrefix}/v1/ldap/groups/:id/rename`, move: `${apiPrefix}/v1/ldap/groups/:id/move`, }, }; } } linagora-ldap-rest-16e557e/src/plugins/ldap/onChange.ts000066400000000000000000000255171522642357000230440ustar00rootroot00000000000000/** * @module core/onLdapChange * Check for ldap modify events and generate hooks: * - onLdapChange * - onLdapMailChange * @author Xavier Guimard */ import { Entry } from 'ldapts'; import type { Request } from 'express'; import DmPlugin, { type Role } from '../../abstract/plugin'; import type { Hooks } from '../../hooks'; import type { AttributeValue, SearchResult } from '../../lib/ldapActions'; import { launchHooks } from '../../lib/utils'; import type { Config } from '../../bin'; export type ChangesToNotify = Record< string, [AttributeValue | null, AttributeValue | null] >; const events: { [configParam: keyof Config]: keyof Hooks; } = { mail_attribute: 'onLdapMailChange', quota_attribute: 'onLdapQuotaChange', alias_attribute: 'onLdapAliasChange', forward_attribute: 'onLdapForwardChange', display_name_attribute: 'onLdapDisplayNameChange', drive_quota_attribute: 'onLdapDriveQuotaChange', }; class OnLdapChange extends DmPlugin { name = 'onLdapChange'; roles: Role[] = ['consistency'] as const; stack: Record = {}; // Store entries before deletion to trigger notify with proper changes pendingDeletions: Map = new Map(); hooks: Hooks = { /** * When a user is added, trigger notify with all new attributes */ ldapadddone: ( args: [string, import('../../lib/ldapActions').AttributesList] ) => { const [dn, attributes] = args; // Build ChangesToNotify with ALL attributes from the new entry const res: ChangesToNotify = {}; for (const [key, value] of Object.entries(attributes)) { res[key] = [null, value]; } // Trigger notify which will call appropriate hooks based on configured attributes this.notify(dn, res); }, ldapmodifyrequest: async ([dn, attributes, op]) => { const tmp = (await this.server.ldap.search( { paged: false }, dn )) as SearchResult; if (tmp.searchEntries.length == 1) { this.stack[op] = tmp.searchEntries[0]; } else { this.logger.warn( `Could not find unique entry ${dn} before modification, got ${tmp.searchEntries.length} entries` ); } return [dn, attributes, op]; }, ldapmodifydone: ([dn, changes, op]) => { const prev = this.stack[op]; if (!prev) { delete this.stack[op]; this.logger.warn( `Received a ldapmodifydone for an unknown operation (${op})` ); return; } const res: ChangesToNotify = {}; if (changes.add) { for (const [key, value] of Object.entries(changes.add)) { res[key] = [null, value]; } } if (changes.delete) { if (Array.isArray(changes.delete)) { for (const attr of changes.delete) { res[attr] = [prev[attr], null]; } } else { for (const [key, value] of Object.entries(changes.delete)) { res[key] = [value, null]; } } } if (changes.replace) { for (const [key, value] of Object.entries(changes.replace)) { res[key] = [prev[key], value]; } } this.notify(dn, res); }, /** * Before deletion, capture entry to trigger notify with proper changes */ ldapdeleterequest: async ([dn, req]: [string | string[], Request?]) => { const dns = Array.isArray(dn) ? dn : [dn]; for (const userDn of dns) { try { const result = await this.server.ldap.search( { scope: 'base', paged: false, }, userDn ); const entry = (result as SearchResult).searchEntries?.[0]; if (entry) { this.pendingDeletions.set(userDn, entry); } // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { // Entry might not exist, ignore } } return [dn, req] as [string | string[], Request?]; }, /** * After deletion, trigger notify with all attributes set to null */ ldapdeletedone: (dn: string | string[]) => { const dns = Array.isArray(dn) ? dn : [dn]; for (const userDn of dns) { const entry = this.pendingDeletions.get(userDn); if (entry) { this.pendingDeletions.delete(userDn); // Build ChangesToNotify with ALL attributes from the deleted entry const res: ChangesToNotify = {}; for (const [key, value] of Object.entries(entry)) { // Skip the dn attribute if (key === 'dn') continue; res[key] = [value, null]; } // Trigger notify which will call appropriate hooks based on configured attributes this.notify(userDn, res); } } }, }; notify(dn: string, changes: ChangesToNotify): void { void launchHooks(this.server.hooks.onLdapChange, dn, changes); for (const [configParam, hookName] of Object.entries(events)) { if ( this.config[configParam] && changes[this.config[configParam] as string] ) { // Special handling for hooks that need mail parameter if ( hookName === 'onLdapQuotaChange' || hookName === 'onLdapForwardChange' || hookName === 'onLdapAliasChange' ) { void this.notifyAttributeChangeWithMail( this.config[configParam] as string, hookName, dn, changes ); } else if (hookName === 'onLdapMailChange') { // Only mail change uses the simple notification this.notifyAttributeChange( this.config[configParam] as string, hookName, dn, changes ); } else if (hookName === 'onLdapDriveQuotaChange') { // Drive quota change - expects numbers, no mail needed const [oldValue, newValue] = changes[this.config[configParam] as string] || []; const oldQuota = oldValue ? Number(oldValue) : null; const newQuota = newValue ? Number(newValue) : null; if (oldQuota !== newQuota) { void launchHooks( this.server.hooks.onLdapDriveQuotaChange, dn, oldQuota, newQuota ); } } } } // Trigger onLdapDisplayNameChange if cn, givenName or sn changed if (changes.cn || changes.givenName || changes.sn) { // Reconstruct old and new display names from changed attributes const oldDisplayName = this.reconstructDisplayName(changes, 0); const newDisplayName = this.reconstructDisplayName(changes, 1); void launchHooks( this.server.hooks.onLdapDisplayNameChange, dn, oldDisplayName, newDisplayName ); } } notifyAttributeChange( attribute: string, hookName: keyof Hooks, dn: string, changes: ChangesToNotify, stringOnly: boolean = false ): void { const [oldValue, newValue] = changes[attribute] || []; if (oldValue === undefined && newValue === undefined) return; if (stringOnly && (Array.isArray(oldValue) || Array.isArray(newValue))) { this.logger.error( `Attribute ${attribute} change detected but one of the values is an array, cannot handle that` ); return; } if (oldValue !== newValue) { void launchHooks(this.server.hooks[hookName], dn, oldValue, newValue); } } async notifyAttributeChangeWithMail( attribute: string, hookName: keyof Hooks, dn: string, changes: ChangesToNotify ): Promise { const [oldValue, newValue] = changes[attribute] || []; if (oldValue === undefined && newValue === undefined) return; // Get current mail address (needed for hooks that require mail parameter) const mailAttr = this.config.mail_attribute || 'mail'; const mailChange = changes[mailAttr]; let mail: string; if (mailChange) { // Mail is changing, use new mail mail = Array.isArray(mailChange[1]) ? String(mailChange[1][0]) : String(mailChange[1]); } else { // Mail not changing, fetch from LDAP try { const result = (await this.server.ldap.search( { paged: false, scope: 'base', attributes: [mailAttr] }, dn )) as SearchResult; if (result.searchEntries.length === 1) { const mailValue = result.searchEntries[0][mailAttr]; mail = Array.isArray(mailValue) ? String(mailValue[0]) : String(mailValue); } else { this.logger.warn( `Could not find mail for ${dn}, skipping ${hookName} notification` ); return; } } catch (err) { this.logger.error(`Error fetching mail for ${dn}:`, err); return; } } // Handle different hook types if (hookName === 'onLdapQuotaChange') { // Quota change - expects numbers const oldQuota = oldValue ? Number(oldValue) : 0; const newQuota = newValue ? Number(newValue) : 0; if (oldQuota !== newQuota) { void launchHooks( this.server.hooks[hookName], dn, mail, oldQuota, newQuota ); } } else if ( hookName === 'onLdapForwardChange' || hookName === 'onLdapAliasChange' ) { // Forward/Alias change - expects arrays of strings const oldArray = oldValue ? Array.isArray(oldValue) ? (oldValue as string[]) : [oldValue as string] : []; const newArray = newValue ? Array.isArray(newValue) ? (newValue as string[]) : [newValue as string] : []; if (oldArray.length > 0 || newArray.length > 0) { void launchHooks( this.server.hooks[hookName], dn, mail, oldArray, newArray ); } } } /** * Reconstruct display name from cn, givenName, and sn attributes * @param changes - The changes object * @param index - 0 for old value, 1 for new value * @returns The reconstructed display name or null */ reconstructDisplayName( changes: ChangesToNotify, index: 0 | 1 ): string | null { const getValue = (attr: string): string | null => { if (!changes[attr]) return null; const value = changes[attr][index]; if (!value) return null; if (Array.isArray(value)) return value.length > 0 ? String(value[0]) : null; return String(value); }; // Try cn first const cn = getValue('cn'); if (cn) return cn; // Try givenName + sn const givenName = getValue('givenName'); const sn = getValue('sn'); if (givenName || sn) { const parts = []; if (givenName) parts.push(givenName); if (sn) parts.push(sn); return parts.join(' '); } return null; } } export default OnLdapChange; linagora-ldap-rest-16e557e/src/plugins/ldap/organization.ts000066400000000000000000000007441522642357000240210ustar00rootroot00000000000000/** * @deprecated Use `core/ldap/organizations` instead. This file is a * backwards-compatibility shim for setups that still load the plugin under * the historical (singular) filename. It will be removed in a future major * release. */ import LdapOrganizations from './organizations'; // eslint-disable-next-line no-console console.warn( '[ldap-rest] Plugin `core/ldap/organization` is deprecated; use `core/ldap/organizations` instead.' ); export default LdapOrganizations; linagora-ldap-rest-16e557e/src/plugins/ldap/organizations.ts000066400000000000000000001217151522642357000242060ustar00rootroot00000000000000import fs from 'fs'; import type { SearchResult } from 'ldapts'; import type { Express, Request, Response } from 'express'; import DmPlugin, { type Role } from '../../abstract/plugin'; import { DM } from '../../bin'; import { Hooks } from '../../hooks'; import { AttributesList, AttributeValue, ModifyRequest, } from '../../lib/ldapActions'; import { tryMethodData, tryMethod, jsonBody, wantJson, } from '../../lib/expressFormatedResponses'; import { asyncHandler, escapeDnValue, escapeLdapFilter, getParentDn, getRdn, isChildOf, launchHooksChained, transformSchemas, validateDnValue, } from '../../lib/utils'; import { BadRequestError, NotFoundError, ConflictError, } from '../../lib/errors'; import type { Schema } from '../../config/schema'; /** * Shared OpenAPI schemas surfaced by this plugin. Picked up by * scripts/generate-openapi.ts and merged into `components.schemas`. * * @openapi-component * Organization: * type: object * description: An LDAP organizational unit entry. * required: [dn, ou] * properties: * dn: * type: string * description: Fully-qualified distinguished name of the entry. * example: ou=Engineering,ou=organizations,dc=example,dc=com * ou: * type: string * description: Organizational unit name (RDN attribute). * example: Engineering * o: * type: string * description: Organization display name. * example: Engineering Department * l: * type: string * description: Locality / city. * example: Paris * description: * type: string * example: Software engineering division * organizationLink: * type: string * description: DN of the parent organization (link attribute). * example: ou=organizations,dc=example,dc=com * organizationPath: * type: string * description: | * Human-readable slash-separated path to this node, used by the * UI tree. Set automatically on creation and move. * example: Engineering / Backend * member: * type: array * items: { type: string } * description: DNs of entries linked to this organization. * example: * - uid=alice,ou=users,dc=example,dc=com * OrgSummary: * type: object * description: Lightweight organization reference (used in node lists). * required: [dn, ou] * properties: * dn: { type: string, example: ou=HR,ou=organizations,dc=example,dc=com } * ou: { type: string, example: HR } * description: { type: string } * objectClass: * type: array * items: { type: string } * example: [top, organizationalUnit] * OrganizationCreate: * type: object * required: [ou] * properties: * ou: * type: string * description: Name of the new organizational unit. * example: Marketing * parentDn: * type: string * description: | * DN of the parent organizational unit. Defaults to the configured * top organization when omitted. * example: ou=organizations,dc=example,dc=com * description: { type: string } * l: { type: string } * example: * ou: Marketing * parentDn: ou=organizations,dc=example,dc=com * description: Marketing division * OrganizationModify: * type: object * description: | * Partial update. Any provided attribute is replaced wholesale. * `ou` cannot be changed via this endpoint — use the move endpoint * to relocate the node within the tree. * properties: * description: { type: string } * l: { type: string } * o: { type: string } * example: * description: Updated description for Marketing */ export default class LdapOrganizations extends DmPlugin { name = 'ldapOrganizations'; roles: Role[] = ['api', 'consistency', 'configurable'] as const; pathAttr: string; linkAttr: string; schema?: Schema; constructor(dm: DM) { super(dm); if (!this.config.ldap_top_organization) { throw new Error('Missing --ldap-top-organization'); } this.pathAttr = this.config.ldap_organization_path_attribute as string; this.linkAttr = this.config.ldap_organization_link_attribute as string; // Load organization schema if provided if (this.config.organization_schema) { fs.readFile(this.config.organization_schema, (err, data) => { if (err) { this.logger.error( `Failed to load organization schema from ${this.config.organization_schema}: ${err}` ); } else { try { this.schema = JSON.parse( transformSchemas(data.toString(), this.config) ) as Schema; this.logger.debug('Organization schema loaded'); } catch (e) { this.logger.error( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Failed to parse organization schema: ${e}` ); } } }); } } /** * API routes for LDAP organizations * @param app Express application */ api(app: Express): void { /** * @openapi * summary: Get top organization * description: | * Returns the single root organizational unit configured via * `--ldap-top-organization`. Authorization plugins may filter the result * to only the branches the caller has access to. * responses: * '200': * description: Top organization entry. * content: * application/json: * schema: { $ref: '#/components/schemas/Organization' } * example: * dn: ou=organizations,dc=example,dc=com * ou: organizations * description: Root organization * objectClass: [top, organizationalUnit] * '404': * description: Top organization not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Simple method to get top organization app.get( `${this.config.api_prefix}/v1/ldap/organizations/top`, async (req, res) => { await tryMethodData(res, this.getOrganisationTop.bind(this), req); } ); /** * @openapi * summary: Get organization by DN * description: | * The `:dn` segment must be the URL-encoded fully-qualified DN of the * organizational unit (e.g. `ou=Engineering%2Cou=organizations%2Cdc%3Dexample%2Cdc%3Dcom`). * Returns a 404 when the DN does not exist or does not have * `objectClass=organizationalUnit`. * responses: * '200': * description: Organization entry. * content: * application/json: * schema: { $ref: '#/components/schemas/Organization' } * example: * dn: ou=Engineering,ou=organizations,dc=example,dc=com * ou: Engineering * description: Software engineering division * objectClass: [top, organizationalUnit] * '404': * description: Organization not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Get organization by DN app.get( `${this.config.api_prefix}/v1/ldap/organizations/:dn`, async (req, res) => { const dn = decodeURIComponent(req.params.dn); await tryMethodData(res, this.getOrganisationByDn.bind(this), dn, req); } ); /** * @openapi * summary: List subnodes of an organization * description: | * Returns up to `ldap_organization_max_subnodes` (default 50) entries * that are either direct child organizational units **or** entries (users, * groups) whose organization-link attribute points to `:dn`. * * When the result is truncated a special sentinel entry with * `objectClass: [moreIndicator]` is appended that carries `_totalCount` * and `_displayedCount` fields. * parameters: * - in: query * name: objectClass * schema: { type: string } * description: | * Filter linked entities by objectClass (e.g. `inetOrgPerson`, * `groupOfNames`). Pass `organizationalUnit` to return only child OUs. * example: inetOrgPerson * responses: * '200': * description: List of subnodes. * content: * application/json: * schema: * type: array * items: { $ref: '#/components/schemas/OrgSummary' } * example: * - dn: ou=Backend,ou=Engineering,ou=organizations,dc=example,dc=com * ou: Backend * objectClass: [top, organizationalUnit] * - dn: uid=alice,ou=users,dc=example,dc=com * uid: alice * cn: Alice Smith * objectClass: [top, inetOrgPerson] * '404': * description: Organization not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Get subnodes of an organization app.get( `${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes`, async (req, res) => { const dn = decodeURIComponent(req.params.dn); await tryMethodData( res, this.getOrganisationSubnodes.bind(this), dn, req ); } ); /** * @openapi * summary: Search subnodes of an organization * description: | * Full-text search across child OUs and linked entries (users, groups) * of `:dn`. The `q` parameter is matched against `ou`, `description`, * `uid`, `cn`, `sn`, `givenName`, and `mail`. Results are not paginated. * parameters: * - in: query * name: q * required: true * schema: { type: string } * description: Search query matched against common attributes. * example: alice * responses: * '200': * description: Matching entries. * content: * application/json: * schema: * type: array * items: { $ref: '#/components/schemas/OrgSummary' } * example: * - dn: uid=alice,ou=users,dc=example,dc=com * uid: alice * cn: Alice Smith * mail: alice@example.com * '400': * description: Query parameter `q` missing. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Search in organization subnodes app.get( `${this.config.api_prefix}/v1/ldap/organizations/:dn/subnodes/search`, asyncHandler(async (req, res) => { const dn = decodeURIComponent(req.params.dn as string); const query = req.query.q as string; if (!query) throw new BadRequestError('query parameter "q" is required'); await tryMethodData( res, this.searchOrganisationSubnodes.bind(this), dn, query ); }) ); /** * @openapi * summary: Create organization * description: | * Creates a new organizational unit. The `ou` field becomes the RDN. * `parentDn` controls where in the tree the node is placed; it defaults * to the configured top organization. * requestBody: * required: true * content: * application/json: * schema: { $ref: '#/components/schemas/OrganizationCreate' } * responses: * '200': * description: Organization created. * content: * application/json: * example: { success: true } * '400': * description: Validation error (missing `ou`, invalid DN value, …). * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } * '409': * description: Organization already exists. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Add organization app.post( `${this.config.api_prefix}/v1/ldap/organizations`, asyncHandler(async (req, res) => this.apiAdd(req, res)) ); /** * @openapi * summary: Modify organization * description: | * Partial attribute update. Each key in the body replaces the existing * value for that attribute. The `ou` RDN and the organization path/link * attributes cannot be changed through this endpoint. * requestBody: * required: true * content: * application/json: * schema: { $ref: '#/components/schemas/OrganizationModify' } * responses: * '200': * description: Organization updated. * content: * application/json: * example: { success: true } * '400': * description: Validation error. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } * '404': * description: Organization not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Modify organization app.put( `${this.config.api_prefix}/v1/ldap/organizations/:dn`, asyncHandler(async (req, res) => this.apiModify(req, res)) ); /** * @openapi * summary: Delete organization * description: | * Deletes the organizational unit identified by `:dn`. The operation is * rejected with **409 Conflict** when any entry still references this * organization via the organization-link attribute (i.e. the OU is not * empty). * responses: * '200': * description: Organization deleted. * content: * application/json: * example: { success: true } * '404': * description: Organization not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } * '409': * description: Organization is not empty. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Delete organization app.delete( `${this.config.api_prefix}/v1/ldap/organizations/:dn`, asyncHandler(async (req, res) => this.apiDelete(req, res)) ); /** * @openapi * summary: Move organization to another parent * description: | * Performs an LDAP `modifyDN` to relocate the organizational unit `:dn` * under `targetOrgDn`. The new DN is returned in the response. * Moving into a descendant or the current parent is rejected. * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [targetOrgDn] * properties: * targetOrgDn: * type: string * description: DN of the destination organizational unit. * example: * targetOrgDn: ou=divisions,ou=organizations,dc=example,dc=com * responses: * '200': * description: Organization moved. Returns the new DN. * content: * application/json: * schema: * type: object * properties: * newDn: { type: string } * example: * newDn: ou=Engineering,ou=divisions,ou=organizations,dc=example,dc=com * '400': * description: Invalid target (circular move, same location, …). * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } * '404': * description: Source or target organization not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Move organization to a different parent app.post( `${this.config.api_prefix}/v1/ldap/organizations/:dn/move`, asyncHandler(async (req, res) => this.apiMove(req, res)) ); } async apiAdd(req: Request, res: Response): Promise { const body = jsonBody(req, res, 'ou') as | { ou: string; parentDn?: string; [key: string]: AttributeValue | undefined; } | false; if (!body) return; validateDnValue(body.ou, 'ou'); const parentDn = body.parentDn || this.config.ldap_top_organization; const dn = `ou=${escapeDnValue(body.ou)},${parentDn}`; const entry: AttributesList = { objectClass: this.config.ldap_organization_class as string[], ou: body.ou, ...Object.fromEntries( Object.entries(body).filter( ([key]) => key !== 'ou' && key !== 'parentDn' ) ), }; await tryMethod(res, this.addOrganization.bind(this), dn, entry, req); } async apiModify(req: Request, res: Response): Promise { const body = jsonBody(req, res) as ModifyRequest | false; if (!body) return; const dn = decodeURIComponent(req.params.dn as string); if (!dn) throw new BadRequestError('dn is required'); await tryMethod(res, this.modifyOrganization.bind(this), dn, body); } async apiDelete(req: Request, res: Response): Promise { if (!wantJson(req, res)) return; const dn = decodeURIComponent(req.params.dn as string); if (!dn) throw new BadRequestError('dn is required'); await tryMethod(res, this.deleteOrganization.bind(this), dn); } async apiMove(req: Request, res: Response): Promise { if (!wantJson(req, res)) return; const body = jsonBody(req, res, 'targetOrgDn') as | { targetOrgDn: string } | false; if (!body) return; const dn = decodeURIComponent(req.params.dn as string); if (!dn) throw new BadRequestError('dn is required'); const { targetOrgDn } = body; if (!targetOrgDn || typeof targetOrgDn !== 'string') { throw new BadRequestError( 'Missing or invalid targetOrgDn in request body' ); } await tryMethodData( res, this.moveOrganization.bind(this), dn, targetOrgDn, req ); } /** * Consistency checks on any entry */ hooks: Hooks = { /** * If ldap_organization_link_attribute and/or ldap_organization_path_attribute * are modified, check that: * - the link attribute points to an existing organization dn * - the path attribute is valid (starts with ldap_top_organization and * each part is separated by ldap_organization_path_separator) * * If an ou is going to be deleted, check that it is empty */ ldapaddrequest: async ([dn, entry, req]) => { // Organizations use LDAP hierarchy (DN), not twakeDepartmentLink // Only users/groups have twakeDepartmentLink if (!this.isOu(entry)) { await this.checkDeptLink(entry); } // Only check path for organizations, not for users/groups if (this.isOu(entry)) await this.checkDeptPath(entry); return req !== undefined ? [dn, entry, req] : ([dn, entry] as [string, AttributesList, Request?]); }, ldapmodifyrequest: async ([dn, changes, op]) => { let fakeEntryL: AttributesList = {}; let fakeEntryP: AttributesList = {}; let isOrgEntry: boolean | undefined; // Determine if this is an organization entry const checkIsOu = async (): Promise => { if (isOrgEntry !== undefined) return isOrgEntry; if (changes.replace?.objectClass) { isOrgEntry = this.isOu({ objectClass: changes.replace.objectClass }); } else if (changes.add?.objectClass) { isOrgEntry = this.isOu({ objectClass: changes.add.objectClass }); } else { const entry = await this.server.ldap.search( { paged: false, scope: 'base' }, dn ); isOrgEntry = (entry as SearchResult).searchEntries.length > 0 && this.isOu((entry as SearchResult).searchEntries[0]); } return isOrgEntry; }; /** * Deletion of path/link attribute is forbidden * - Organizations cannot delete path (they use LDAP hierarchy, not link) * - Users/groups cannot delete link or path */ if (changes.delete) { const hasLinkDelete = Array.isArray(changes.delete) ? changes.delete.includes(this.linkAttr) : changes.delete[this.linkAttr]; const hasPathDelete = Array.isArray(changes.delete) ? changes.delete.includes(this.pathAttr) : changes.delete[this.pathAttr]; if (hasLinkDelete || hasPathDelete) { const isOu = await checkIsOu(); if (!isOu && hasLinkDelete) { throw new BadRequestError(`An organization link cannot be deleted`); } if (hasPathDelete) { throw new BadRequestError(`An organization path cannot be deleted`); } } } /** * If link/path attribute is modified, check its validity * - Organizations: only validate path * - Users/groups: validate both link and path */ if (changes.replace) { if (changes.replace[this.linkAttr]) fakeEntryL = { ...changes.replace }; if (changes.replace[this.pathAttr]) fakeEntryP = { ...changes.replace }; } if (changes.add) { if (changes.add[this.linkAttr]) fakeEntryL = { ...fakeEntryL, ...changes.add }; if (changes.add[this.pathAttr]) fakeEntryP = { ...fakeEntryP, ...changes.add }; } // Organizations use LDAP hierarchy, not twakeDepartmentLink if (Object.keys(fakeEntryL).length > 0) { const isOu = await checkIsOu(); if (!isOu) { await this.checkDeptLink(fakeEntryL); } } if (Object.keys(fakeEntryP).length > 0) { const isOu = await checkIsOu(); if (isOu) { await this.checkDeptPath(fakeEntryP); } } return [dn, changes, op]; }, ldapdeleterequest: async ([dn, req]: [string | string[], Request?]) => { // Deletion of a non empty organization is forbidden const targets = Array.isArray(dn) ? dn : [dn]; for (const target of targets) { if (/^ou=/.test(target)) await this.isEmptyOrganization(target); } return [dn, req] as [string | string[], Request?]; }, ldaprenamerequest: ([dn, newdn]) => { return [dn, newdn]; }, }; /** * Check if the department link is valid * @param entry LDAP entry to check */ async checkDeptLink(entry: AttributesList): Promise { if (entry[this.linkAttr]) { const linkValue = entry[this.linkAttr]; const orgDn = ( Array.isArray(linkValue) ? linkValue[0] : linkValue ) as string; // Use scope: 'base' to benefit from LDAP cache const res = await this.server.ldap.search( { paged: false, scope: 'base' }, orgDn ); if ((res as SearchResult).searchEntries.length === 0) throw new NotFoundError(`Organization ${orgDn} does not exist`); if ( !new RegExp(`(.*,)?${this.config.ldap_top_organization}`).test( (res as SearchResult).searchEntries[0].dn ) ) throw new BadRequestError( `Entry ${orgDn} isn't in top organization branch` ); } } /** * Check if the department path is valid * @param entry LDAP entry to check */ async checkDeptPath(entry: AttributesList): Promise { if (entry[this.pathAttr]) { const pathValue = entry[this.pathAttr]; const path = ( Array.isArray(pathValue) ? pathValue[0] : pathValue ) as string; const sep = this.config.ldap_organization_path_separator || ' / '; let matchingPath = path; if (this.isOu(entry)) { const ouValue = entry.ou; const ouName = ( Array.isArray(ouValue) ? ouValue[0] : ouValue ) as string; if (!path.startsWith(ouName + sep)) throw new BadRequestError( `Organization path must start with its own name followed by separator "${sep}"` ); matchingPath = path.slice(ouName.length + sep.length); } const [ou, ouPath] = matchingPath.split(sep, 2); // Search will benefit from cache for repeated validations const entries = await this.server.ldap.search( { paged: false, filter: `(ou=${escapeLdapFilter(ou)})`, scope: 'sub', }, this.config.ldap_top_organization ); if ((entries as SearchResult).searchEntries.length === 0) throw new BadRequestError(`Invalid organization path ${path}`); // If ouPath is undefined, this references the top organization // Verify it exists and has no parent path (or matches top org DN) if (!ouPath) { let found = false; for (const entry of (entries as SearchResult).searchEntries) { const entryDn = entry.dn; const topOrgDn = this.config.ldap_top_organization as string; // Check if this is the top organization (DN matches or is direct child) if ( entryDn.toLowerCase() === topOrgDn.toLowerCase() || entryDn.toLowerCase().endsWith(`,${topOrgDn.toLowerCase()}`) ) { const pathValue = entry[this.pathAttr]; const entryPath = Array.isArray(pathValue) ? (pathValue[0] as string | undefined) : (pathValue as string | undefined); // Top org should either have no path attribute or a simple path (just its name) if (!entryPath || entryPath === ou || !entryPath.includes(sep)) { found = true; break; } } } if (!found) throw new BadRequestError( `Invalid organization path ${path}: no matching top-level entry for ${ou}` ); } else { // Verify parent organization exists with the specified path let found = false; for (const entry of (entries as SearchResult).searchEntries) { const pathValue = entry[this.pathAttr]; const entryPath = Array.isArray(pathValue) ? (pathValue[0] as string) : (pathValue as string); if (entryPath && entryPath === ouPath) { found = true; break; } } if (!found) throw new BadRequestError( `Invalid organization path ${path}: no matching entry for ${ou} with path ${ouPath}` ); } } } async isEmptyOrganization(dn: string): Promise { const res = await this.server.ldap.search({ paged: false, filter: `(${this.config.ldap_organization_link_attribute}=${dn})`, }); if ((res as SearchResult).searchEntries.length > 0) throw new ConflictError(`Organization ${dn} is not empty`); } /** * Check if entry is an organisation * @param entry LDAP entry to check * @returns True if entry is an organisation, false otherwise */ isOu(entry: AttributesList): boolean { if (!entry.objectClass) return false; const entryClasses = (entry.objectClass as string[]).map(c => c.toLowerCase() ); return (this.config.ldap_organization_class as string[]) .filter(c => c.toLowerCase() !== 'top') .some(c => entryClasses.includes(c.toLowerCase())); } async getOrganisationTop( req?: Request ): Promise { if (!this.config.ldap_top_organization) throw new BadRequestError('No top organization configured'); // Get default top organization const top = await this.server.ldap.search( { paged: false, scope: 'base' }, this.config.ldap_top_organization, req ); if ((top as SearchResult).searchEntries.length !== 1) throw new NotFoundError('Top organization not found'); // Call hook to allow plugins (like authzPerBranch) to modify the result const [, result] = await launchHooksChained( this.registeredHooks.getOrganisationTop, [req, (top as SearchResult).searchEntries[0]] ); return result; } async getOrganisationByDn( dn: string, req?: Request ): Promise { const org = await this.server.ldap.search( { paged: false, scope: 'base', filter: '(objectClass=organizationalUnit)', }, dn, req ); if ((org as SearchResult).searchEntries.length !== 1) throw new NotFoundError(`Organization ${dn} not found`); return (org as SearchResult).searchEntries[0]; } async getOrganisationSubnodes( dn: string, req?: Request ): Promise { const result: AttributesList[] = []; const MAX_LINKED_ENTITIES = this.config.ldap_organization_max_subnodes || 50; // Check if objectClass filter is requested via query parameter const objectClassFilter = req?.query?.objectClass as string | undefined; // 1. Get direct sub-OUs (children organizational units) if (!objectClassFilter || objectClassFilter === 'organizationalUnit') { try { const subOUs = (await this.server.ldap.search( { paged: false, scope: 'one', filter: '(objectClass=organizationalUnit)', }, dn, req )) as SearchResult; this.server.logger.debug( `Found ${subOUs.searchEntries.length} sub-OUs for ${dn}` ); result.push(...subOUs.searchEntries); } catch (err) { // Ignore errors (e.g., if no children exist) // eslint-disable-next-line @typescript-eslint/restrict-template-expressions this.server.logger.debug(`No sub-OUs found for ${dn}: ${err}`); } } // 2. Get linked entities (users and groups) - limited to MAX_LINKED_ENTITIES // Need to search from LDAP base, not just organization tree // (e.g., "dc=example,dc=com" from "ou=organizations,dc=example,dc=com") const topOrg = this.config.ldap_top_organization as string; const baseDn = getParentDn(topOrg); let filter = `(${this.config.ldap_organization_link_attribute}=${escapeLdapFilter( dn )})`; // Add objectClass filter if specified if (objectClassFilter) { filter = `(&${filter}(objectClass=${escapeLdapFilter( objectClassFilter )}))`; } this.server.logger.debug( `Searching for linked entities with filter: ${filter} in ${baseDn}` ); const subs = await this.server.ldap.search( { paged: true, filter, }, baseDn, req ); let linkedCount = 0; let totalCount = 0; for await (const sub of subs as AsyncGenerator) { totalCount += sub.searchEntries.length; const remaining = MAX_LINKED_ENTITIES - linkedCount; if (remaining > 0) { const entriesToAdd = sub.searchEntries.slice(0, remaining); result.push(...entriesToAdd); linkedCount += entriesToAdd.length; } } this.server.logger.debug( `Found ${totalCount} linked entities for ${dn}, returning ${linkedCount}` ); // Add a special indicator entry if there are more elements if (totalCount > MAX_LINKED_ENTITIES) { result.push({ dn: `more-${dn}`, cn: [`... ${totalCount - MAX_LINKED_ENTITIES} more elements`], objectClass: ['moreIndicator'], _isMoreIndicator: 'true', _totalCount: totalCount.toString(), _displayedCount: MAX_LINKED_ENTITIES.toString(), }); } return result; } async addOrganization( dn: string, entry: AttributesList, req?: Request ): Promise { // Validate with schema if available this.validateNewOrganization(dn, entry); // Hooks will validate the organization link and path return await this.server.ldap.add(dn, entry, req); } async modifyOrganization( dn: string, changes: ModifyRequest ): Promise { // Validate with schema if available this.validateChanges(dn, changes); // Hooks will validate any changes to organization link and path return await this.server.ldap.modify(dn, changes); } validateNewOrganization(dn: string, entry: AttributesList): boolean { if (!this.schema) return true; // Check each field for (const [field, value] of Object.entries(entry)) { if (!this._validateOneChange(field, value)) { throw new BadRequestError(`Invalid value for field ${field}`); } } // Check required fields for (const [field, test] of Object.entries(this.schema.attributes)) { if (test.required && entry[field] == undefined) throw new BadRequestError(`Missing required field ${field}`); } return true; } validateChanges(dn: string, changes: ModifyRequest): boolean { if (!this.schema) return true; if (changes.add) { for (const [field, value] of Object.entries(changes.add)) { if (!this._validateOneChange(field, value)) { throw new BadRequestError(`Invalid value for field ${field}`); } } } if (changes.replace) { for (const [field, value] of Object.entries(changes.replace)) { if (!this._validateOneChange(field, value)) { throw new BadRequestError(`Invalid value for field ${field}`); } } } return true; } _validateOneChange(field: string, value: AttributeValue | null): boolean { if (!this.schema) return true; const fieldTest = this.schema.attributes[field]; if (!fieldTest) { if (this.schema.strict) throw new Error(`Field ${field} is not allowed`); return true; } if (value === null || value === undefined) { if (fieldTest.required) throw new Error(`Field ${field} is required`); return true; } // Type validation if (fieldTest.type) { switch (fieldTest.type) { case 'string': if (Array.isArray(value)) throw new Error(`Field ${field} must be a single value`); break; case 'array': if (!Array.isArray(value)) throw new Error(`Field ${field} must be an array`); break; case 'pointer': // Pointer validation is handled by other hooks break; } } // Test/pattern validation if (fieldTest.test) { const valueStr = Array.isArray(value) ? value[0] : value; const regex = typeof fieldTest.test === 'string' ? new RegExp(fieldTest.test) : fieldTest.test; if (!regex.test(String(valueStr))) throw new Error(`Field ${field} does not match required pattern`); } return true; } /** * Move an organization to a different parent organization * Uses LDAP modifyDN to change the DN hierarchy */ async moveOrganization( dn: string, targetOrgDn: string, req?: Request ): Promise<{ newDn: string }> { // Validate that target organization exists. // ldap.search throws on NoSuchObject; treat both that and an empty // result set as "not found" (404). let targetOrg: SearchResult; try { targetOrg = (await this.server.ldap.search( { paged: false, scope: 'base' }, targetOrgDn )) as SearchResult; } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new NotFoundError( `Target organization ${targetOrgDn} not found: ${err}` ); } if (!targetOrg.searchEntries || targetOrg.searchEntries.length === 0) { throw new NotFoundError(`Target organization ${targetOrgDn} not found`); } if (!this.isOu(targetOrg.searchEntries[0])) { throw new BadRequestError( `Target ${targetOrgDn} is not an organizational unit` ); } // Extract the RDN (relative DN) from the source DN // e.g., "ou=IT,ou=Departments,dc=example,dc=com" -> "ou=IT" const rdn = getRdn(dn); // Construct the new DN const newDn = `${rdn},${targetOrgDn}`; // Verify the organization to move exists let sourceOrg: SearchResult; try { sourceOrg = (await this.server.ldap.search( { paged: false, scope: 'base' }, dn )) as SearchResult; } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new NotFoundError(`Source organization not found: ${err}`); } if (!sourceOrg.searchEntries || sourceOrg.searchEntries.length === 0) { throw new NotFoundError(`Source organization ${dn} not found`); } // Prevent moving to itself or creating circular references if (newDn === dn) { throw new BadRequestError( 'Cannot move organization to its current location' ); } // Reject moving into self or any descendant (case-insensitive DN compare) if ( targetOrgDn.toLowerCase() === dn.toLowerCase() || isChildOf(targetOrgDn, dn) ) { throw new BadRequestError( 'Cannot move organization into itself or its descendant' ); } // Perform the LDAP modifyDN operation try { await this.server.ldap.rename(dn, newDn, req); this.logger.info(`Moved organization from ${dn} to ${newDn}`); } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Failed to move organization: ${err}`); } return { newDn }; } async deleteOrganization(dn: string): Promise { // Hook will check that organization is empty before deletion return await this.server.ldap.delete(dn); } async searchOrganisationSubnodes( dn: string, query: string ): Promise { const result: AttributesList[] = []; // Search for sub-OUs matching the query // Escape query to prevent LDAP injection const escapedQuery = escapeLdapFilter(query); try { const subOUs = (await this.server.ldap.search( { paged: false, scope: 'one', filter: `(&(objectClass=organizationalUnit)(|(ou=*${escapedQuery}*)(description=*${escapedQuery}*)))`, }, dn )) as SearchResult; this.server.logger.debug( `Found ${subOUs.searchEntries.length} sub-OUs matching "${query}" for ${dn}` ); result.push(...subOUs.searchEntries); } catch (err) { this.server.logger.debug( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `No sub-OUs found matching "${query}" for ${dn}: ${err}` ); } // Search for linked entities (users and groups) matching the query const topOrg = this.config.ldap_top_organization as string; const baseDn = getParentDn(topOrg); // Escape both dn and query to prevent LDAP injection const escapedDn = escapeLdapFilter(dn); const filter = `(&(${this.config.ldap_organization_link_attribute}=${escapedDn})(|(uid=*${escapedQuery}*)(cn=*${escapedQuery}*)(mail=*${escapedQuery}*)(sn=*${escapedQuery}*)(givenName=*${escapedQuery}*)))`; this.server.logger.debug( `Searching for linked entities with filter: ${filter} in ${baseDn}` ); const subs = await this.server.ldap.search( { paged: true, filter, }, baseDn ); let linkedCount = 0; for await (const sub of subs as AsyncGenerator) { linkedCount += sub.searchEntries.length; result.push(...sub.searchEntries); } this.server.logger.debug( `Found ${linkedCount} linked entities matching "${query}" for ${dn}` ); return result; } /** * Provide configuration for config API */ getConfigApiData(): Record { const apiPrefix = this.config.api_prefix || '/api'; // Generate schema URL if static plugin is loaded let schemaUrl: string | undefined; if ( this.server.loadedPlugins['static'] && this.config.organization_schema ) { const staticName = this.config.static_name || 'static'; const schemasIndex = this.config.organization_schema.indexOf('/schemas/'); if (schemasIndex !== -1) { const relativePath = this.config.organization_schema.substring(schemasIndex); schemaUrl = `/${staticName}${relativePath}`; } } return { enabled: true, topOrganization: this.config.ldap_top_organization || '', organizationClass: this.config.ldap_organization_class || [ 'top', 'organizationalUnit', ], linkAttribute: this.linkAttr || '', pathAttribute: this.pathAttr || '', pathSeparator: this.config.ldap_organization_path_separator || ' / ', maxSubnodes: this.config.ldap_organization_max_subnodes || 50, schema: this.schema, schemaUrl, endpoints: { getTop: `${apiPrefix}/v1/ldap/organizations/top`, get: `${apiPrefix}/v1/ldap/organizations/:dn`, getSubnodes: `${apiPrefix}/v1/ldap/organizations/:dn/subnodes`, searchSubnodes: `${apiPrefix}/v1/ldap/organizations/:dn/subnodes/search`, create: `${apiPrefix}/v1/ldap/organizations`, update: `${apiPrefix}/v1/ldap/organizations/:dn`, move: `${apiPrefix}/v1/ldap/organizations/:dn/move`, delete: `${apiPrefix}/v1/ldap/organizations/:dn`, }, }; } } linagora-ldap-rest-16e557e/src/plugins/ldap/passwordPolicy.ts000066400000000000000000000775261522642357000243530ustar00rootroot00000000000000/** * Password Policy plugin - Administration API for OpenLDAP ppolicy * * Provides REST endpoints to: * - Query password status (expiration, lockout, etc.) * - Unlock locked accounts * - List expiring passwords and locked accounts * - Validate password complexity (optional) * * @author Xavier Guimard */ import type { Express, Request, Response } from 'express'; import DmPlugin, { type Role, asyncHandler, BadRequestError, NotFoundError, } from '../../abstract/plugin'; import type { SearchResult } from '../../lib/ldapActions'; import { escapeDnValue } from '../../lib/utils'; // ppolicy operational attributes to read const PPOLICY_ATTRS = [ 'pwdChangedTime', 'pwdAccountLockedTime', 'pwdFailureTime', 'pwdGraceUseTime', 'pwdReset', 'pwdPolicySubentry', ]; // ppolicy configuration attributes const PPOLICY_CONFIG_ATTRS = [ 'pwdMaxAge', 'pwdMinAge', 'pwdInHistory', 'pwdCheckQuality', 'pwdMinLength', 'pwdMaxFailure', 'pwdLockout', 'pwdLockoutDuration', 'pwdGraceAuthNLimit', 'pwdExpireWarning', 'pwdMustChange', 'pwdAllowUserChange', ]; // Special characters for password complexity validation const SPECIAL_CHARS_REGEX = /[!@#$%^&*()_+\-=[\]{}|;:'",.<>?/]/; interface PpolicyConfig { dn?: string; pwdMaxAge?: number; pwdMinAge?: number; pwdInHistory?: number; pwdCheckQuality?: number; pwdMinLength?: number; pwdMaxFailure?: number; pwdLockout?: boolean; pwdLockoutDuration?: number; pwdGraceAuthNLimit?: number; pwdExpireWarning?: number; pwdMustChange?: boolean; pwdAllowUserChange?: boolean; } interface PasswordStatus { dn: string; passwordSet: boolean; lastChanged: string | null; expiresAt: string | null; daysUntilExpiration: number | null; isExpired: boolean; isExpiringSoon: boolean; mustChange: boolean; isLocked: boolean; lockedAt: string | null; failureCount: number; graceLoginsUsed: number; } interface ExpiringUser { dn: string; uid: string | undefined; displayName: string | undefined; mail: string | undefined; expiresAt: string; daysUntilExpiration: number; } interface LockedAccount { dn: string; uid: string | undefined; displayName: string | undefined; mail: string | undefined; lockedAt: string | undefined; failureCount: number; } interface ValidationResult { valid: boolean; errors: string[]; } // LDAP entry with ppolicy operational attributes interface LdapUserEntry { dn: string; userPassword?: string; uid?: string; cn?: string; displayName?: string; mail?: string; pwdChangedTime?: string; pwdAccountLockedTime?: string; pwdFailureTime?: string[]; pwdGraceUseTime?: string[]; pwdReset?: string; pwdPolicySubentry?: string; } // LDAP ppolicy configuration entry interface LdapPolicyEntry { dn: string; pwdMaxAge?: string; pwdMinAge?: string; pwdInHistory?: string; pwdCheckQuality?: string; pwdMinLength?: string; pwdMaxFailure?: string; pwdLockout?: string; pwdLockoutDuration?: string; pwdGraceAuthNLimit?: string; pwdExpireWarning?: string; pwdMustChange?: string; pwdAllowUserChange?: string; } /** * Shared OpenAPI schemas surfaced by this plugin. Picked up by * scripts/generate-openapi.ts and merged into `components.schemas`. * * @openapi-component * PasswordPolicy: * type: object * description: | * Active OpenLDAP ppolicy configuration. All duration fields are in * **seconds**. Absent fields mean the policy attribute is not set. * properties: * dn: * type: string * description: DN of the ppolicy entry. * example: cn=default,ou=policies,dc=example,dc=com * pwdMaxAge: * type: integer * description: Maximum password age in seconds (0 = never expires). * example: 7776000 * pwdMinAge: * type: integer * description: Minimum password age in seconds before it may be changed. * example: 0 * pwdInHistory: * type: integer * description: Number of previous passwords stored. * example: 5 * pwdCheckQuality: * type: integer * description: Password quality enforcement level (0-2). * example: 1 * pwdMinLength: * type: integer * description: Minimum password length. * example: 12 * pwdMaxFailure: * type: integer * description: Maximum bind failures before lockout. * example: 5 * pwdLockout: * type: boolean * description: Whether account lockout is enabled. * example: true * pwdLockoutDuration: * type: integer * description: Lockout duration in seconds (0 = permanent until admin reset). * example: 300 * pwdGraceAuthNLimit: * type: integer * description: Number of grace logins allowed after password expires. * example: 0 * pwdExpireWarning: * type: integer * description: Seconds before expiry at which a warning is issued. * example: 1209600 * pwdMustChange: * type: boolean * description: If true, users must change their password after an admin reset. * example: true * pwdAllowUserChange: * type: boolean * description: Whether users are allowed to change their own password. * example: true * PasswordStatus: * type: object * description: Current password status for a specific user account. * required: [dn, passwordSet, isExpired, isExpiringSoon, mustChange, isLocked, failureCount, graceLoginsUsed] * properties: * dn: * type: string * description: Fully-qualified DN of the user. * example: uid=alice,ou=users,dc=example,dc=com * passwordSet: * type: boolean * description: Whether a userPassword attribute is present. * example: true * lastChanged: * type: string * format: date-time * nullable: true * description: ISO-8601 timestamp of last password change, or null if never set. * example: '2026-01-15T10:00:00.000Z' * expiresAt: * type: string * format: date-time * nullable: true * description: ISO-8601 expiry timestamp, or null if no max age is configured. * example: '2026-04-15T10:00:00.000Z' * daysUntilExpiration: * type: integer * nullable: true * description: Remaining days until expiry (negative means already expired). * example: 80 * isExpired: * type: boolean * example: false * isExpiringSoon: * type: boolean * description: True when expiry is within the configured warning window. * example: false * mustChange: * type: boolean * description: True when `pwdReset` is set (admin forced change). * example: false * isLocked: * type: boolean * description: True when the account is currently locked. * example: false * lockedAt: * type: string * format: date-time * nullable: true * description: ISO-8601 timestamp of lockout, or null. * example: null * failureCount: * type: integer * description: Number of recorded bind failures still within the window. * example: 0 * graceLoginsUsed: * type: integer * description: Number of grace logins already consumed. * example: 0 * ExpiringAccount: * type: object * description: User whose password is expiring within the warning window. * required: [dn, expiresAt, daysUntilExpiration] * properties: * dn: { type: string, example: uid=bob,ou=users,dc=example,dc=com } * uid: { type: string, example: bob } * displayName: { type: string, example: Bob Builder } * mail: { type: string, example: bob@example.com } * expiresAt: * type: string * format: date-time * example: '2026-05-01T08:00:00.000Z' * daysUntilExpiration: { type: integer, example: 6 } * LockedAccount: * type: object * description: User account that is currently locked. * required: [dn, failureCount] * properties: * dn: { type: string, example: uid=charlie,ou=users,dc=example,dc=com } * uid: { type: string, example: charlie } * displayName: { type: string, example: Charlie Chaplin } * mail: { type: string, example: charlie@example.com } * lockedAt: * type: string * format: date-time * nullable: true * example: '2026-04-24T14:30:00.000Z' * failureCount: { type: integer, example: 5 } * PasswordValidationRequest: * type: object * required: [password] * properties: * password: * type: string * format: password * description: The candidate password to validate. * example: 'S3cur3P@ss!' * dn: * type: string * description: Optional user DN for context (not used in local validation). * example: uid=alice,ou=users,dc=example,dc=com * PasswordValidationResponse: * type: object * required: [valid, errors] * properties: * valid: * type: boolean * description: True when the password meets all complexity rules. * example: true * errors: * type: array * items: { type: string } * description: List of failed rule descriptions (empty when valid). * example: [] */ export default class PasswordPolicy extends DmPlugin { name = 'ldapPasswordPolicy'; roles: Role[] = ['api', 'configurable']; private policyCache: PpolicyConfig | null = null; private policyCacheTime = 0; private readonly POLICY_CACHE_TTL = 60000; // 1 minute api(app: Express): void { const prefix = `${this.config.api_prefix}/v1`; /** * @openapi * summary: Get password policy * description: | * Returns the active OpenLDAP ppolicy configuration read from the DN * configured via `--ppolicy-default-dn`, or discovered by searching for * `objectClass=pwdPolicy` when that option is absent. * Results are cached for 60 seconds. * responses: * '200': * description: Active password policy. * content: * application/json: * schema: { $ref: '#/components/schemas/PasswordPolicy' } * example: * dn: cn=default,ou=policies,dc=example,dc=com * pwdMinLength: 12 * pwdMaxAge: 7776000 * pwdMaxFailure: 5 * pwdLockout: true * pwdLockoutDuration: 300 * pwdMustChange: true * pwdAllowUserChange: true * pwdInHistory: 5 * pwdGraceAuthNLimit: 0 */ // GET /password-policy - configuration app.get( `${prefix}/password-policy`, asyncHandler(async (_req: Request, res: Response) => { const policy = await this.readPolicyConfig(); res.json(policy); }) ); /** * @openapi * summary: Get user password status * description: | * Returns the current password state for user `:id`. The `id` may be * a plain `uid` value or a full DN. The response is computed from the * user's ppolicy operational attributes combined with the active policy. * responses: * '200': * description: Password status for the user. * content: * application/json: * schema: { $ref: '#/components/schemas/PasswordStatus' } * example: * dn: uid=alice,ou=users,dc=example,dc=com * passwordSet: true * lastChanged: '2026-01-15T10:00:00.000Z' * expiresAt: '2026-04-15T10:00:00.000Z' * daysUntilExpiration: 80 * isExpired: false * isExpiringSoon: false * mustChange: false * isLocked: false * lockedAt: null * failureCount: 0 * graceLoginsUsed: 0 * '404': * description: User not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // GET /users/:id/password-status app.get( `${prefix}/users/:id/password-status`, asyncHandler(async (req: Request, res: Response) => { const userId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const status = await this.getPasswordStatus(userId); res.json(status); }) ); /** * @openapi * summary: Unlock user account * description: | * Removes the `pwdAccountLockedTime` and `pwdFailureTime` operational * attributes from the user entry, effectively clearing the lockout state. * If the account was not locked the operation still succeeds (idempotent). * responses: * '200': * description: Account unlocked (or was already unlocked). * content: * application/json: * example: { success: true, message: Account unlocked } * '404': * description: User not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // POST /users/:id/unlock app.post( `${prefix}/users/:id/unlock`, asyncHandler(async (req: Request, res: Response) => { const userId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; await this.unlockAccount(userId); res.json({ success: true, message: 'Account unlocked' }); }) ); /** * @openapi * summary: List accounts with expiring passwords * description: | * Scans the user base for accounts whose password will expire within * `days` days. Results are sorted ascending by `daysUntilExpiration`. * Returns an empty list when no `pwdMaxAge` is configured. * parameters: * - in: query * name: days * schema: { type: integer, default: 14 } * description: Warning window in days (defaults to `--ppolicy-warn-days` or 14). * example: 7 * responses: * '200': * description: List of expiring accounts with their warning window. * content: * application/json: * schema: * type: object * properties: * warningDays: { type: integer, example: 7 } * users: * type: array * items: { $ref: '#/components/schemas/ExpiringAccount' } * example: * warningDays: 7 * users: * - dn: uid=bob,ou=users,dc=example,dc=com * uid: bob * displayName: Bob Builder * mail: bob@example.com * expiresAt: '2026-05-01T08:00:00.000Z' * daysUntilExpiration: 6 */ // GET /password-policy/expiring-soon app.get( `${prefix}/password-policy/expiring-soon`, asyncHandler(async (req: Request, res: Response) => { const days = parseInt(req.query.days as string) || (this.config.ppolicy_warn_days as number) || 14; const users = await this.findExpiringPasswords(days); res.json({ warningDays: days, users }); }) ); /** * @openapi * summary: List locked accounts * description: | * Returns all user entries in the user base that have a * `pwdAccountLockedTime` attribute set, regardless of whether the * lockout has expired. Use the `/unlock` endpoint to clear individual * lockouts. * responses: * '200': * description: List of locked accounts. * content: * application/json: * schema: * type: object * properties: * accounts: * type: array * items: { $ref: '#/components/schemas/LockedAccount' } * example: * accounts: * - dn: uid=charlie,ou=users,dc=example,dc=com * uid: charlie * displayName: Charlie Chaplin * mail: charlie@example.com * lockedAt: '2026-04-24T14:30:00.000Z' * failureCount: 5 */ // GET /password-policy/locked-accounts app.get( `${prefix}/password-policy/locked-accounts`, asyncHandler(async (_req: Request, res: Response) => { const accounts = await this.findLockedAccounts(); res.json({ accounts }); }) ); // POST /password/validate (optional) if (this.config.ppolicy_validate_complexity) { /** * @openapi * summary: Validate password complexity * description: | * Validates a candidate password against the locally-configured complexity * rules (`--ppolicy-min-length`, `--ppolicy-require-uppercase`, etc.). * This endpoint is only registered when `--ppolicy-validate-complexity` is * enabled. It does **not** call LDAP — it runs a local rule check only. * requestBody: * required: true * content: * application/json: * schema: { $ref: '#/components/schemas/PasswordValidationRequest' } * responses: * '200': * description: Validation result. * content: * application/json: * schema: { $ref: '#/components/schemas/PasswordValidationResponse' } * example: * valid: false * errors: * - Minimum 12 characters required * - At least one special character required * '400': * description: Missing `password` field. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ app.post( `${prefix}/password/validate`, // eslint-disable-next-line @typescript-eslint/require-await asyncHandler(async (req: Request, res: Response) => { const { password } = req.body as { password?: string }; if (!password) throw new BadRequestError('password required'); const result = this.validateComplexity(password); res.json(result); }) ); } } /** * Get password status for a user */ private async getPasswordStatus(userId: string): Promise { const dn = this.resolveUserDn(userId); try { // Search with operational attributes const result = (await this.server.ldap.search( { filter: '(objectClass=*)', scope: 'base', attributes: ['*', ...PPOLICY_ATTRS], paged: false, }, dn )) as SearchResult; if (!result.searchEntries || result.searchEntries.length === 0) { throw new NotFoundError(`User not found: ${userId}`); } const entry = result.searchEntries[0] as LdapUserEntry; const policy = await this.readPolicyConfig(); return this.calculateStatus(dn, entry, policy); } catch (error) { // Handle LDAP NoSuchObjectError (0x20) if ( error instanceof Error && (error.name === 'NoSuchObjectError' || error.message.includes('0x20') || error.message.includes('Code: 32')) ) { throw new NotFoundError(`User not found: ${userId}`); } throw error; } } /** * Calculate password status from LDAP entry and policy */ private calculateStatus( dn: string, entry: LdapUserEntry, policy: PpolicyConfig ): PasswordStatus { const now = Date.now(); // Parse pwdChangedTime const lastChanged = this.parseGeneralizedTime(entry.pwdChangedTime); // Calculate expiration let expiresAt: Date | null = null; let isExpired = false; let daysUntilExpiration: number | null = null; if (policy.pwdMaxAge && lastChanged) { expiresAt = new Date(lastChanged.getTime() + policy.pwdMaxAge * 1000); isExpired = now > expiresAt.getTime(); daysUntilExpiration = Math.ceil((expiresAt.getTime() - now) / 86400000); } // Check warning period let isExpiringSoon = false; const warnDays = (this.config.ppolicy_warn_days as number) || 14; if (daysUntilExpiration !== null && daysUntilExpiration > 0) { isExpiringSoon = daysUntilExpiration <= warnDays; } // Check lockout const lockedTime = entry.pwdAccountLockedTime; let isLocked = !!lockedTime; const isPermanentLock = lockedTime === '000001010000Z'; // Calculate lockout end (auto-unlock if duration passed) if (isLocked && !isPermanentLock && policy.pwdLockoutDuration) { const lockedAt = this.parseGeneralizedTime(lockedTime); if (lockedAt) { const lockoutEndsAt = new Date( lockedAt.getTime() + policy.pwdLockoutDuration * 1000 ); if (now > lockoutEndsAt.getTime()) { isLocked = false; } } } // Count failures (pwdFailureTime is multi-valued GeneralizedTime) const failures: unknown = entry.pwdFailureTime; const failureCount = Array.isArray(failures) ? failures.length : 0; // Grace logins used (pwdGraceUseTime is multi-valued GeneralizedTime) const graceLogins: unknown = entry.pwdGraceUseTime; const graceLoginsUsed = Array.isArray(graceLogins) ? graceLogins.length : 0; // Must change password const mustChange = entry.pwdReset === 'TRUE'; return { dn, passwordSet: !!entry.userPassword, lastChanged: lastChanged?.toISOString() || null, expiresAt: expiresAt?.toISOString() || null, daysUntilExpiration, isExpired, isExpiringSoon, mustChange, isLocked, lockedAt: lockedTime ? this.parseGeneralizedTime(lockedTime)?.toISOString() || null : null, failureCount, graceLoginsUsed, }; } /** * Unlock a user account by removing pwdAccountLockedTime and pwdFailureTime */ private async unlockAccount(userId: string): Promise { const dn = this.resolveUserDn(userId); // First check if user exists const result = (await this.server.ldap.search( { filter: '(objectClass=*)', scope: 'base', attributes: ['pwdAccountLockedTime', 'pwdFailureTime'], paged: false, }, dn )) as SearchResult; if (!result.searchEntries || result.searchEntries.length === 0) { throw new NotFoundError(`User not found: ${userId}`); } const entry = result.searchEntries[0] as LdapUserEntry; // Build delete changes for existing attributes only const deleteAttrs: Record = {}; if (entry.pwdAccountLockedTime) { deleteAttrs.pwdAccountLockedTime = []; } if (entry.pwdFailureTime) { deleteAttrs.pwdFailureTime = []; } if (Object.keys(deleteAttrs).length > 0) { await this.server.ldap.modify(dn, { delete: deleteAttrs, }); this.logger.info(`Account unlocked: ${dn}`); } else { this.logger.info(`Account was not locked: ${dn}`); } } /** * Find users with passwords expiring soon */ private async findExpiringPasswords(days: number): Promise { const policy = await this.readPolicyConfig(); // If no pwdMaxAge, passwords don't expire if (!policy.pwdMaxAge) { return []; } // Search users with pwdChangedTime const base = (this.config.ldap_users_base as string) || (this.config.ldap_base as string); const result = (await this.server.ldap.search( { filter: '(pwdChangedTime=*)', scope: 'sub', attributes: ['uid', 'cn', 'displayName', 'mail', 'pwdChangedTime'], paged: false, }, base )) as SearchResult; const now = Date.now(); const warningMs = days * 24 * 60 * 60 * 1000; const expiringUsers: ExpiringUser[] = []; for (const entry of result.searchEntries) { const e = entry as LdapUserEntry; const changed = this.parseGeneralizedTime(e.pwdChangedTime); if (!changed) continue; const expiresAt = new Date(changed.getTime() + policy.pwdMaxAge * 1000); const remaining = expiresAt.getTime() - now; if (remaining > 0 && remaining < warningMs) { expiringUsers.push({ dn: e.dn, uid: e.uid, displayName: e.displayName || e.cn, mail: e.mail, expiresAt: expiresAt.toISOString(), daysUntilExpiration: Math.ceil(remaining / 86400000), }); } } // Sort by expiration date (soonest first) return expiringUsers.sort( (a, b) => a.daysUntilExpiration - b.daysUntilExpiration ); } /** * Find locked accounts */ private async findLockedAccounts(): Promise { const base = (this.config.ldap_users_base as string) || (this.config.ldap_base as string); const result = (await this.server.ldap.search( { filter: '(pwdAccountLockedTime=*)', scope: 'sub', attributes: [ 'uid', 'cn', 'displayName', 'mail', 'pwdAccountLockedTime', 'pwdFailureTime', ], paged: false, }, base )) as SearchResult; return result.searchEntries.map(entry => { const e = entry as LdapUserEntry; return { dn: e.dn, uid: e.uid, displayName: e.displayName || e.cn, mail: e.mail, lockedAt: this.parseGeneralizedTime( e.pwdAccountLockedTime )?.toISOString(), failureCount: Array.isArray(e.pwdFailureTime) ? e.pwdFailureTime.length : 0, }; }); } /** * Read ppolicy configuration from LDAP */ private async readPolicyConfig(): Promise { // Check cache if ( this.policyCache && Date.now() - this.policyCacheTime < this.POLICY_CACHE_TTL ) { return this.policyCache; } const policyDn = this.config.ppolicy_default_dn as string; if (policyDn) { // Read specific policy DN try { const result = (await this.server.ldap.search( { filter: '(objectClass=*)', scope: 'base', attributes: PPOLICY_CONFIG_ATTRS, paged: false, }, policyDn )) as SearchResult; if (result.searchEntries.length > 0) { this.policyCache = this.parsePolicyEntry( result.searchEntries[0] as LdapPolicyEntry, policyDn ); this.policyCacheTime = Date.now(); return this.policyCache; } } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions this.logger.warn(`Failed to read ppolicy from ${policyDn}: ${error}`); } } // Search for default ppolicy try { const result = (await this.server.ldap.search( { filter: '(objectClass=pwdPolicy)', scope: 'sub', attributes: PPOLICY_CONFIG_ATTRS, paged: false, }, this.config.ldap_base as string )) as SearchResult; if (result.searchEntries.length > 0) { const entry = result.searchEntries[0] as LdapPolicyEntry; this.policyCache = this.parsePolicyEntry(entry, entry.dn); this.policyCacheTime = Date.now(); return this.policyCache; } } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions this.logger.debug(`No ppolicy found: ${error}`); } // No policy found, return empty config this.policyCache = {}; this.policyCacheTime = Date.now(); return this.policyCache; } /** * Parse ppolicy entry to config object */ private parsePolicyEntry(entry: LdapPolicyEntry, dn: string): PpolicyConfig { return { dn, pwdMaxAge: this.parseNumber(entry.pwdMaxAge), pwdMinAge: this.parseNumber(entry.pwdMinAge), pwdInHistory: this.parseNumber(entry.pwdInHistory), pwdCheckQuality: this.parseNumber(entry.pwdCheckQuality), pwdMinLength: this.parseNumber(entry.pwdMinLength), pwdMaxFailure: this.parseNumber(entry.pwdMaxFailure), pwdLockout: entry.pwdLockout === 'TRUE', pwdLockoutDuration: this.parseNumber(entry.pwdLockoutDuration), pwdGraceAuthNLimit: this.parseNumber(entry.pwdGraceAuthNLimit), pwdExpireWarning: this.parseNumber(entry.pwdExpireWarning), pwdMustChange: entry.pwdMustChange === 'TRUE', pwdAllowUserChange: entry.pwdAllowUserChange === undefined || entry.pwdAllowUserChange === 'TRUE', }; } /** * Parse number from LDAP attribute value */ private parseNumber(value: string | undefined): number | undefined { if (value === undefined) return undefined; const num = parseInt(value, 10); return isNaN(num) ? undefined : num; } /** * Parse GeneralizedTime (YYYYMMDDHHmmssZ) to Date */ private parseGeneralizedTime(value: string | undefined): Date | null { if (!value) return null; // Format: YYYYMMDDHHmmss[.fraction]Z const match = value.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/); if (!match) return null; const [, y, m, d, h, min, s] = match; return new Date(Date.UTC(+y, +m - 1, +d, +h, +min, +s)); } /** * Validate password complexity (optional local validation) */ private validateComplexity(password: string): ValidationResult { const errors: string[] = []; const cfg = this.config; const minLength = (cfg.ppolicy_min_length as number) || 12; if (password.length < minLength) { errors.push(`Minimum ${minLength} characters required`); } if (cfg.ppolicy_require_uppercase && !/[A-Z]/.test(password)) { errors.push('At least one uppercase letter required'); } if (cfg.ppolicy_require_lowercase && !/[a-z]/.test(password)) { errors.push('At least one lowercase letter required'); } if (cfg.ppolicy_require_digit && !/\d/.test(password)) { errors.push('At least one digit required'); } if (cfg.ppolicy_require_special && !SPECIAL_CHARS_REGEX.test(password)) { errors.push('At least one special character required'); } return { valid: errors.length === 0, errors }; } /** * Resolve user DN from userId (uid or full DN) * Escapes special characters in userId to prevent LDAP injection */ private resolveUserDn(userId: string): string { if (userId.includes('=')) return userId; // Already a DN const base = (this.config.ldap_users_base as string) || (this.config.ldap_base as string); const attr = (this.config.ldap_user_main_attribute as string) || 'uid'; return `${attr}=${escapeDnValue(userId)},${base}`; } /** * Provide configuration data for config API */ getConfigApiData(): Record | undefined { const apiPrefix = this.config.api_prefix || '/api'; const validateComplexity = !!this.config.ppolicy_validate_complexity; return { name: this.name, enabled: true, endpoints: { getPolicy: `${apiPrefix}/v1/password-policy`, getUserStatus: `${apiPrefix}/v1/users/:id/password-status`, unlockUser: `${apiPrefix}/v1/users/:id/unlock`, getExpiringSoon: `${apiPrefix}/v1/password-policy/expiring-soon`, getLockedAccounts: `${apiPrefix}/v1/password-policy/locked-accounts`, validatePassword: validateComplexity ? `${apiPrefix}/v1/password/validate` : undefined, }, config: { warnDays: this.config.ppolicy_warn_days || 14, validateComplexity, // Include complexity rules when validation is enabled ...(validateComplexity && { complexityRules: { minLength: this.config.ppolicy_min_length || 12, requireUppercase: this.config.ppolicy_require_uppercase ?? true, requireLowercase: this.config.ppolicy_require_lowercase ?? true, requireDigit: this.config.ppolicy_require_digit ?? true, requireSpecial: this.config.ppolicy_require_special ?? true, }, }), }, // Include cached LDAP ppolicy if available ldapPolicy: this.policyCache || undefined, }; } } linagora-ldap-rest-16e557e/src/plugins/ldap/trash.ts000066400000000000000000000214161522642357000224350ustar00rootroot00000000000000/** * @module core/trash * LDAP Trash System - Intercepts delete operations and moves entries to a trash branch * * When a delete operation occurs on a watched LDAP branch: * 1. Check if DN is in a watched branch (configured via DM_TRASH_WATCHED_BASES) * 2. Remove any existing entry with same RDN in trash (overwrite old trash) * 3. Use LDAP modifyDN to atomically move entry to trash branch * 4. Add metadata (deletedAt, originalDN) as description attribute * 5. Cancel the native delete operation * * @author Xavier Guimard */ import type { Request } from 'express'; import DmPlugin, { type Role } from '../../abstract/plugin'; import type { Hooks } from '../../hooks'; import type { SearchResult } from '../../lib/ldapActions'; class TrashPlugin extends DmPlugin { name = 'trash'; roles: Role[] = ['consistency'] as const; private watchedBases: string[] = []; private trashBase: string = ''; private addMetadata: boolean = true; private autoCreateTrash: boolean = true; private trashInitialized: boolean = false; constructor(dm: typeof DmPlugin.prototype.server) { super(dm); // Parse configuration const trashBase = this.config.trash_base; this.trashBase = typeof trashBase === 'string' ? trashBase : 'ou=trash,dc=example,dc=com'; const watchedBases = this.config.trash_watched_bases; if (typeof watchedBases === 'string' && watchedBases) { this.watchedBases = watchedBases .split(',') .map((base: string) => base.trim()) .filter((base: string) => base.length > 0); } this.addMetadata = this.config.trash_add_metadata !== 'false'; this.autoCreateTrash = this.config.trash_auto_create !== 'false'; this.logger.info( `Trash plugin initialized: base=${this.trashBase}, watched=${this.watchedBases.join(',')}, metadata=${this.addMetadata}` ); } /** * Check if a DN is in a watched branch */ private isWatched(dn: string): boolean { // NEVER intercept deletes from trash itself (prevent infinite loop) // Use proper DN suffix matching to avoid false positives // (e.g., "uid=trash-user,ou=people" should not match "ou=trash") if (dn === this.trashBase || dn.endsWith(',' + this.trashBase)) { return false; } if (this.watchedBases.length === 0) { // If no watched bases configured, watch everything except trash return true; } // Use proper DN suffix matching to avoid false positives // (e.g., "ou=users" should not match "uid=users-admin,ou=people") return this.watchedBases.some( base => dn === base || dn.endsWith(',' + base) ); } /** * Extract RDN from DN (e.g., "uid=john" from "uid=john,ou=users,dc=example,dc=com") */ private extractRDN(dn: string): string { const parts = dn.split(','); return parts[0]; } /** * Build trash DN from original DN * uid=john,ou=users,dc=example,dc=com -> uid=john,ou=trash,dc=example,dc=com */ private buildTrashDN(dn: string): string { const rdn = this.extractRDN(dn); return `${rdn},${this.trashBase}`; } /** * Ensure trash branch exists in LDAP */ private async ensureTrashExists(): Promise { if (this.trashInitialized) return; if (!this.autoCreateTrash) { this.trashInitialized = true; return; } try { const result = (await this.server.ldap.search( { paged: false }, this.trashBase )) as SearchResult; if (result.searchEntries.length > 0) { this.logger.debug(`Trash branch ${this.trashBase} already exists`); this.trashInitialized = true; return; } } catch { // Branch doesn't exist, try to create it this.logger.info(`Creating trash branch ${this.trashBase}`); } try { const rdn = this.extractRDN(this.trashBase); const ouName = rdn.split('=')[1]; await this.server.ldap.add(this.trashBase, { objectClass: ['top', 'organizationalUnit'], ou: ouName, description: 'LDAP Trash - Deleted entries are moved here', }); this.logger.info(`Trash branch ${this.trashBase} created successfully`); this.trashInitialized = true; } catch (error) { this.logger.error( `Failed to create trash branch ${this.trashBase}: ${String(error)}` ); throw new Error( `Trash plugin: Unable to create trash branch ${this.trashBase}. Please create it manually or set DM_TRASH_AUTO_CREATE=false` ); } } /** * Remove old entry from trash if it exists (to allow overwrite) */ private async removeOldTrashEntry(trashDn: string): Promise { try { const result = (await this.server.ldap.search( { paged: false }, trashDn )) as SearchResult; if (result.searchEntries.length > 0) { this.logger.info( `Removing old trash entry ${trashDn} before overwrite` ); await this.server.ldap.delete(trashDn); this.logger.debug(`Old trash entry ${trashDn} deleted`); } } catch (error) { // Entry doesn't exist, that's fine this.logger.debug( `No old trash entry to remove at ${trashDn}: ${String(error)}` ); } } /** * Add metadata to trash entry (deletedAt, originalDN) */ private async addMetadataToTrash( trashDn: string, originalDn: string ): Promise { if (!this.addMetadata) return; try { const timestamp = new Date().toISOString(); const metadata = `Deleted on ${timestamp} from ${originalDn}`; await this.server.ldap.modify(trashDn, { replace: { description: metadata, }, }); this.logger.debug(`Metadata added to ${trashDn}`); } catch (error) { this.logger.warn( `Failed to add metadata to ${trashDn}: ${String(error)}. Entry moved to trash but without metadata.` ); // Don't throw - metadata is optional } } hooks: Hooks = { /** * Intercept LDAP delete operations and move to trash instead * This is a chained hook that receives a DN or array of DNs * and can modify or filter them before deletion */ ldapdeleterequest: async ([dn, req]: [string | string[], Request?]) => { // Convert to array for easier processing const dnsToProcess = Array.isArray(dn) ? dn : [dn]; const dnsToDelete: string[] = []; for (const currentDn of dnsToProcess) { // Check if this DN is in a watched branch if (!this.isWatched(currentDn)) { this.logger.debug( `Trash plugin: ${currentDn} not in watched branches, allowing native delete` ); dnsToDelete.push(currentDn); // Allow native delete continue; } this.logger.info(`Trash plugin: Intercepting delete of ${currentDn}`); // Ensure trash branch exists await this.ensureTrashExists(); // Build trash DN const trashDn = this.buildTrashDN(currentDn); // Remove old trash entry if exists (overwrite) await this.removeOldTrashEntry(trashDn); try { // Use LDAP move to atomically move entry to trash this.logger.debug( `Moving ${currentDn} to ${trashDn} using LDAP move (atomic)` ); await this.server.ldap.move(currentDn, trashDn); this.logger.info(`Entry ${currentDn} moved to trash at ${trashDn}`); // Add metadata (non-blocking) await this.addMetadataToTrash(trashDn, currentDn); // Don't add to dnsToDelete - this DN is already moved to trash } catch (error) { this.logger.error( `Trash plugin: Failed to move ${currentDn} to trash: ${String(error)}` ); // Check if it's a permission error if ( error instanceof Error && (error.message.includes('Insufficient access') || error.message.includes('permissions') || error.message.includes('access denied')) ) { throw new Error( `Trash plugin: Insufficient LDAP permissions to move ${currentDn} to trash. ` + `Please grant modifyDN permissions or disable trash plugin. Original error: ${error.message}` ); } // Re-throw other errors throw new Error( `Trash plugin: Failed to move ${currentDn} to trash: ${String(error)}` ); } } // Return the list of DNs that should still be deleted normally // If input was an array, return array; if single string, return string or undefined // Returning undefined indicates no deletion should occur (all handled by trash) return [ Array.isArray(dn) ? dnsToDelete : (dnsToDelete[0] ?? undefined), req, ] as [string | string[], Request?]; }, }; } export default TrashPlugin; linagora-ldap-rest-16e557e/src/plugins/priority.json000066400000000000000000000003051522642357000225720ustar00rootroot00000000000000[ "core/auth/trustedProxy", "core/weblogs", "core/auth/crowdsec", "core/auth/rateLimit", "core/auth/token", "core/auth/llng", "core/auth/openidconnect", "core/auth/authzPerRoute" ] linagora-ldap-rest-16e557e/src/plugins/rabbitmq.ts000066400000000000000000000177171522642357000222060ustar00rootroot00000000000000/** * @module plugins/rabbitmq * * Shared RabbitMQ transport. Owns a single broker connection for the whole * process and exposes publish/consume so other plugins do not each bundle * (and re-open) their own AMQP client. * * The connection is lazy: it opens on the first publish/subscribe call, not at * load time, so a broker that is down or unconfigured never blocks startup. * `@linagora/rabbitmq-client` is an optional dependency; if the package is not * installed or `rabbitmq_url` is empty, every method is a logged no-op. * * Consumers declare it as a dependency and reach it through the loaded-plugin * registry: * * dependencies = { rabbitmq: 'core/rabbitmq' }; * const rmq = this.server.loadedPlugins['rabbitmq'] as RabbitMq; * await rmq.publish('auth', 'user.created', message); */ import DmPlugin from '../abstract/plugin'; import type { DM } from '../bin'; /** JSON-serialisable message payload. */ export type RabbitMessage = Record; /** Async handler invoked for each consumed message. */ export type RabbitMessageHandler = (message: RabbitMessage) => Promise; /** Options forwarded to the underlying client's `publish()`. */ export interface PublishOptions { maxAttempts?: number; headers?: Record; correlationId?: string; messageId?: string; expiration?: string; } /** Options forwarded to the underlying client's `subscribe()`. */ export interface SubscribeOptions { queueArguments?: Record; } /** * Subset of `@linagora/rabbitmq-client`'s `RabbitMQClient` that this plugin * relies on. Declared locally because the package is an optional dependency * and may be absent at type-check time. */ interface RabbitClient { init(): Promise; publish( exchange: string, routingKey: string, message: RabbitMessage, options?: PublishOptions ): Promise; subscribe( exchange: string, routingKey: string, queue: string, handler: RabbitMessageHandler, options?: SubscribeOptions ): Promise; unsubscribe(queue: string): Promise; close(clearSubscriptions?: boolean): Promise; } export default class RabbitMq extends DmPlugin { name = 'rabbitmq'; private readonly rabbitmqUrl: string; private client: RabbitClient | null = null; private clientInit: Promise | null = null; constructor(server: DM) { super(server); this.rabbitmqUrl = (this.config.rabbitmq_url as string) || ''; if (!this.rabbitmqUrl) { this.logger.warn( `${this.name}: rabbitmq_url is empty — AMQP publishes/subscribes will be skipped` ); } this.registerShutdown(); } /** * Whether the plugin is configured with a broker URL. Lets consumers decide * whether to bother building a message. A `true` value does not guarantee * the broker is reachable — the connection is still lazy. */ isAvailable(): boolean { return this.rabbitmqUrl.length > 0; } /** * Publish a JSON message to a topic exchange. No-op (returns silently) when * no broker is configured or the client is unavailable. */ async publish( exchange: string, routingKey: string, message: RabbitMessage, options?: PublishOptions ): Promise { const client = await this.getClient(); if (!client) return; await client.publish(exchange, routingKey, message, options); } /** * Subscribe a handler to a topic exchange/routing-key on a named queue. * No-op when no broker is configured or the client is unavailable. */ async subscribe( exchange: string, routingKey: string, queue: string, handler: RabbitMessageHandler, options?: SubscribeOptions ): Promise { const client = await this.getClient(); if (!client) return; await client.subscribe(exchange, routingKey, queue, handler, options); } /** Stop consuming from a queue. No-op when the client is unavailable. */ async unsubscribe(queue: string): Promise { const client = await this.getClient(); if (!client) return; await client.unsubscribe(queue); } /** * The live broker client, or null when unavailable. Advanced consumers that * need features beyond publish/subscribe can reach the raw client here. */ async getRawClient(): Promise { return this.getClient(); } /** * Lazy-init the shared AMQP client. The `@linagora/rabbitmq-client` * dependency is optional — if the package or the broker is unreachable, * return null so callers can no-op cleanly. * * Override this in tests to inject a stub client. */ protected async getClient(): Promise { if (this.client) return this.client; if (!this.rabbitmqUrl) return null; if (this.clientInit) return this.clientInit; this.clientInit = (async (): Promise => { try { const client = await this.connect(); this.client = client; this.logger.info( `${this.name}: connected to RabbitMQ at ${redactAmqpUrl( this.rabbitmqUrl )}` ); return client; } catch (err) { this.logger.warn({ plugin: this.name, event: 'rabbitmq_init', message: '@linagora/rabbitmq-client unavailable or broker unreachable — AMQP disabled', // eslint-disable-next-line @typescript-eslint/restrict-template-expressions error: `${err}`, }); // Reset so a later call retries: a transient outage at the first // publish must not disable the broker for the process lifetime. Once // a connect succeeds, the client's own reconnection logic takes over. this.clientInit = null; return null; } })(); return this.clientInit; } /** * Open and initialise the underlying client. Split out so the caching and * retry policy in `getClient()` can be tested without a live broker. */ protected async connect(): Promise { const mod = await import('@linagora/rabbitmq-client'); const client = new mod.RabbitMQClient({ url: this.rabbitmqUrl, }) as unknown as RabbitClient; await client.init(); return client; } private registerShutdown(): void { const shutdown = (): void => { const client = this.client; // Clear both: a closed client must not be returned by a still-pending // initialisation promise. this.client = null; this.clientInit = null; if (!client) return; client.close().catch((err: unknown) => { this.logger.warn({ plugin: this.name, event: 'shutdown', // eslint-disable-next-line @typescript-eslint/restrict-template-expressions error: `${err}`, }); }); }; registerProcessShutdown(shutdown); } } /** * Strip credentials from an AMQP URL before logging — `amqp://user:pass@host` * becomes `amqp://host`. Falls back to `amqp://[broker]` if the URL cannot * be parsed (e.g. malformed config). */ function redactAmqpUrl(url: string): string { try { const u = new URL(url); u.username = ''; u.password = ''; return u.toString(); } catch { return 'amqp://[broker]'; } } /** * Registers a single SIGTERM/SIGINT handler at the process level. Each plugin * instance contributes one callback that fans out from the shared handler, so * we don't accumulate per-instance listeners (which would trip * MaxListenersExceededWarning when many DM instances are created in tests). */ const shutdownCallbacks: Array<() => void> = []; let processShutdownInstalled = false; function registerProcessShutdown(cb: () => void): void { shutdownCallbacks.push(cb); if (processShutdownInstalled) return; processShutdownInstalled = true; const fanOut = (): void => { for (const fn of shutdownCallbacks.splice(0)) { try { fn(); } catch { // Hooks must never throw during shutdown. } } }; process.once('SIGTERM', fanOut); process.once('SIGINT', fanOut); } linagora-ldap-rest-16e557e/src/plugins/scim/000077500000000000000000000000001522642357000207535ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/plugins/scim/baseResolver.ts000066400000000000000000000134511522642357000237630ustar00rootroot00000000000000/** * @module plugins/scim/baseResolver * @author Xavier Guimard * * Resolve per-request LDAP bases for SCIM Users and Groups. * * Resolution order (user → group identical): * 1. Explicit map entry for req.user (scim_base_map → { "": { userBase, groupBase } }) * 2. Request header (scim_user_base_header / scim_group_base_header) * 3. Wildcard map entry "*" (scim_base_map → { "*": { ... } }) * 4. Template substitution (scim_user_base_template / scim_group_base_template) * 5. Static config value (scim_user_base / scim_group_base) * 6. Global fallback (ldap_base) * * The `{user}` placeholder in templates is substituted with req.user after * `escapeDnValue()` sanitization, to prevent DN-injection. * * A header-supplied base is only honored when `scim_base_header_root` is * explicitly configured AND the value sits at or under that root AND contains * no control characters, so a header cannot redirect operations to an arbitrary * DN or inject into the DN. An explicit per-user map entry still wins over the * header, so identity-based pinning is never silently overridden. */ import fs from 'fs'; import type { Config } from '../../config/args'; import type { DmRequest } from '../../lib/auth/base'; import { escapeDnValue, isChildOf } from '../../lib/utils'; export interface BaseMapEntry { userBase?: string; groupBase?: string; } export type BaseMap = Record; export class BaseResolver { private readonly defaultUserBase: string; private readonly defaultGroupBase: string; private readonly userTemplate: string; private readonly groupTemplate: string; private readonly map: BaseMap | undefined; private readonly userBaseHeader: string; private readonly groupBaseHeader: string; private readonly headerRoot: string; constructor(config: Config) { const fallback = config.ldap_base || ''; this.defaultUserBase = (config.scim_user_base as string) || fallback; this.defaultGroupBase = (config.scim_group_base as string) || fallback; this.userTemplate = (config.scim_user_base_template as string) || ''; this.groupTemplate = (config.scim_group_base_template as string) || ''; this.userBaseHeader = ( (config.scim_user_base_header as string) || '' ).toLowerCase(); this.groupBaseHeader = ( (config.scim_group_base_header as string) || '' ).toLowerCase(); this.headerRoot = (config.scim_base_header_root as string) || ''; const mapPath = (config.scim_base_map as string) || ''; if (mapPath) { try { const content = fs.readFileSync(mapPath, 'utf8'); this.map = JSON.parse(content) as BaseMap; } catch (err) { throw new Error( `Failed to load SCIM base map from ${mapPath}: ${ err instanceof Error ? err.message : String(err) }` ); } } } private applyTemplate(template: string, user: string | undefined): string { const safe = user ? escapeDnValue(user) : ''; return template.replace(/\{user\}/g, safe); } /** Read a request header by name, tolerating Express req or a plain object. */ private readHeader( req: DmRequest | { user?: string } | undefined, name: string ): string | undefined { if (!req || typeof req !== 'object') return undefined; const r = req as { get?: (n: string) => string | undefined; headers?: Record; }; if (typeof r.get === 'function') { const v = r.get(name); if (typeof v === 'string' && v.length > 0) return v; } const h = r.headers?.[name]; if (typeof h === 'string' && h.length > 0) return h; if (Array.isArray(h) && typeof h[0] === 'string' && h[0].length > 0) { return h[0]; } return undefined; } private resolve( kind: 'user' | 'group', req?: DmRequest | { user?: string } ): string { const user = req && typeof req === 'object' && 'user' in req ? req.user : undefined; // 1. Explicit map entry (identity pinning wins over a request header) if (this.map && user && this.map[user]) { const entry = this.map[user]; if (kind === 'user' && entry.userBase) return entry.userBase; if (kind === 'group' && entry.groupBase) return entry.groupBase; } // 2. Request header — only when an explicit root is configured, the value // is at/under it, and it carries no control characters. const headerName = kind === 'user' ? this.userBaseHeader : this.groupBaseHeader; if (headerName && this.headerRoot) { const fromHeader = this.readHeader(req, headerName)?.trim(); if ( fromHeader && // eslint-disable-next-line no-control-regex !/[\u0000-\u001f\u007f]/.test(fromHeader) && (fromHeader.toLowerCase() === this.headerRoot.toLowerCase() || isChildOf(fromHeader, this.headerRoot)) ) { return fromHeader; } // No root, outside the root, or unsafe value: ignore, fall through. } // 3. Wildcard map entry if (this.map && this.map['*']) { const entry = this.map['*']; if (kind === 'user' && entry.userBase) return this.applyTemplate(entry.userBase, user); if (kind === 'group' && entry.groupBase) return this.applyTemplate(entry.groupBase, user); } // 3. Template const template = kind === 'user' ? this.userTemplate : this.groupTemplate; if (template) return this.applyTemplate(template, user); // 4. Static / 5. Fallback return kind === 'user' ? this.defaultUserBase : this.defaultGroupBase; } userBase(req?: DmRequest | { user?: string }): string { return this.resolve('user', req); } groupBase(req?: DmRequest | { user?: string }): string { return this.resolve('group', req); } } linagora-ldap-rest-16e557e/src/plugins/scim/bulk.ts000066400000000000000000000201541522642357000222620ustar00rootroot00000000000000/** * @module plugins/scim/bulk * @author Xavier Guimard * * SCIM 2.0 Bulk operations (RFC 7644 §3.7). * * Executes operations sequentially. Supports `bulkId` cross-references: * a POST with `"bulkId":"abc"` creates a resource; subsequent operations * in the same request can reference it as "bulkId:abc" anywhere a SCIM * id or member.value is expected (resolved to the concrete id before * dispatch). */ import type winston from 'winston'; import type { Config } from '../../config/args'; import type { DmRequest } from '../../lib/auth/base'; import { launchHooks } from '../../lib/utils'; import type { ScimUsers } from './users'; import type { ScimGroups } from './groups'; import { type BulkRequest, type BulkResponse, type BulkOperationRequest, type BulkOperationResponse, type ScimUser, type ScimGroup, type PatchRequest, SCHEMA_BULK_RESPONSE, } from './types'; import { scimInvalidValue, writeScimErrorFromException, ScimError, } from './errors'; export interface ScimBulkOptions { config: Config; logger: winston.Logger; users: ScimUsers; groups: ScimGroups; scimPrefix: string; // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type hooks: { [K: string]: Function[] | undefined }; } const BULK_REF_RE = /^bulkId:([A-Za-z0-9_-]+)$/; export class ScimBulk { private readonly config: Config; private readonly logger: winston.Logger; private readonly users: ScimUsers; private readonly groups: ScimGroups; private readonly scimPrefix: string; // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type private readonly hooks: { [K: string]: Function[] | undefined }; constructor(opts: ScimBulkOptions) { this.config = opts.config; this.logger = opts.logger; this.users = opts.users; this.groups = opts.groups; this.scimPrefix = opts.scimPrefix; this.hooks = opts.hooks; } async execute(req: DmRequest, body: BulkRequest): Promise { const maxOps = (this.config.scim_bulk_max_operations as number) || 100; if (!body.Operations || !Array.isArray(body.Operations)) { throw scimInvalidValue('Missing Operations array'); } if (body.Operations.length > maxOps) { throw scimInvalidValue( `Too many operations: ${body.Operations.length} (max ${maxOps})` ); } const refs = new Map(); const responses: BulkOperationResponse[] = []; let errorCount = 0; const failOnErrors = body.failOnErrors ?? 0; for (const op of body.Operations) { if (failOnErrors > 0 && errorCount >= failOnErrors) { break; } const response = await this.executeOne(req, op, refs); responses.push(response); if (parseInt(response.status, 10) >= 400) errorCount++; } const bulkResp: BulkResponse = { schemas: [SCHEMA_BULK_RESPONSE], Operations: responses, }; void launchHooks(this.hooks.scimbulkdone, bulkResp); return bulkResp; } private async executeOne( req: DmRequest, op: BulkOperationRequest, refs: Map ): Promise { const base: BulkOperationResponse = { method: op.method, bulkId: op.bulkId, status: '200', }; try { if (!op.method || !op.path) { throw scimInvalidValue('method and path are required'); } // Resolve bulkId references inside data if (op.data && typeof op.data === 'object') { this.resolveRefs(op.data as Record, refs); } const pathParts = op.path.replace(/^\//, '').split('/'); const resource = pathParts[0]; const maybeId = pathParts[1]; if (resource !== 'Users' && resource !== 'Groups') { throw scimInvalidValue(`Unsupported bulk path '${op.path}'`); } const isUser = resource === 'Users'; // Resolve ID from bulkId if provided let targetId = maybeId; if (targetId && BULK_REF_RE.test(targetId)) { const m = BULK_REF_RE.exec(targetId)!; const ref = refs.get(m[1]); if (!ref) throw scimInvalidValue(`Unknown bulkId reference '${m[1]}'`); targetId = ref.id; } switch (op.method) { case 'POST': { if (targetId) throw scimInvalidValue('POST must not include an id'); const created = isUser ? await this.users.create(req, op.data as ScimUser) : await this.groups.create(req, op.data as ScimGroup); base.status = '201'; base.location = this.buildLocation(resource, created.id!, req); base.version = (created.meta?.version as string) || undefined; if (op.bulkId) { refs.set(op.bulkId, { id: created.id!, type: isUser ? 'User' : 'Group', }); } return base; } case 'PUT': { if (!targetId) throw scimInvalidValue('PUT requires an id'); const replaced = isUser ? await this.users.replace(req, targetId, op.data as ScimUser) : await this.groups.replace(req, targetId, op.data as ScimGroup); base.status = '200'; base.location = this.buildLocation(resource, replaced.id!, req); return base; } case 'PATCH': { if (!targetId) throw scimInvalidValue('PATCH requires an id'); const patched = isUser ? await this.users.patch(req, targetId, op.data as PatchRequest) : await this.groups.patch(req, targetId, op.data as PatchRequest); base.status = '200'; base.location = this.buildLocation(resource, patched.id!, req); return base; } case 'DELETE': { if (!targetId) throw scimInvalidValue('DELETE requires an id'); if (isUser) { await this.users.delete(req, targetId); } else { await this.groups.delete(req, targetId); } base.status = '204'; return base; } default: throw scimInvalidValue( `Unsupported method '${String(op.method as unknown)}'` ); } } catch (err) { const status = err instanceof ScimError ? err.statusCode : 500; base.status = String(status); base.response = { schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'], status: String(status), ...(err instanceof ScimError && err.scimType ? { scimType: err.scimType } : {}), detail: err instanceof Error ? err.message : String(err), }; return base; } } private resolveRefs( obj: Record, refs: Map ): void { for (const [k, v] of Object.entries(obj)) { if (typeof v === 'string') { const m = BULK_REF_RE.exec(v); if (m) { const ref = refs.get(m[1]); if (ref) obj[k] = ref.id; } continue; } if (Array.isArray(v)) { const arr = v as unknown[]; for (let i = 0; i < arr.length; i++) { const item = arr[i]; if (typeof item === 'string') { const m = BULK_REF_RE.exec(item); if (m) { const ref = refs.get(m[1]); if (ref) arr[i] = ref.id; } } else if (item && typeof item === 'object') { this.resolveRefs(item as Record, refs); } } continue; } if (v && typeof v === 'object') { this.resolveRefs(v as Record, refs); } } } private buildLocation( resource: 'Users' | 'Groups', id: string, req?: DmRequest ): string { const fromConfig = (this.config.scim_base_url as string) || ''; const baseUrl = fromConfig ? fromConfig.replace(/\/$/, '') : req?.protocol && req.get ? `${req.protocol}://${String(req.get('host') || '')}` : ''; return `${baseUrl}${this.scimPrefix}/${resource}/${encodeURIComponent(id)}`; } } // Helper for consumers that want to serialize an error via bulk-style export { writeScimErrorFromException }; linagora-ldap-rest-16e557e/src/plugins/scim/discovery.ts000066400000000000000000000137301522642357000233360ustar00rootroot00000000000000/** * @module plugins/scim/discovery * @author Xavier Guimard * * SCIM 2.0 discovery endpoints: * GET /ServiceProviderConfig * GET /ResourceTypes (and /ResourceTypes/User, /ResourceTypes/Group) * GET /Schemas (and /Schemas/{urn}) */ import fs from 'fs'; import path from 'path'; import type { Config } from '../../config/args'; import type { DmRequest } from '../../lib/auth/base'; import { type ResourceTypeDefinition, type ServiceProviderConfig, type SchemaDefinition, SCHEMA_USER, SCHEMA_GROUP, SCHEMA_RESOURCE_TYPE, SCHEMA_SCHEMA, SCHEMA_SERVICE_PROVIDER_CONFIG, } from './types'; export interface DiscoveryOptions { config: Config; schemaDir: string; scimPrefix: string; loadedPlugins: { [name: string]: unknown }; } interface AuthScheme { name: string; description: string; type: string; specUri?: string; documentationUri?: string; primary?: boolean; } export class ScimDiscovery { private readonly config: Config; private readonly schemaDir: string; private readonly scimPrefix: string; private readonly loadedPlugins: { [name: string]: unknown }; constructor(opts: DiscoveryOptions) { this.config = opts.config; this.schemaDir = opts.schemaDir; this.scimPrefix = opts.scimPrefix; this.loadedPlugins = opts.loadedPlugins; } private baseUrl(req?: DmRequest): string { const fromConfig = (this.config.scim_base_url as string) || ''; if (fromConfig) return fromConfig.replace(/\/$/, ''); if (req?.protocol && req.get) { return `${req.protocol}://${String(req.get('host') || '')}`; } return ''; } private location(req: DmRequest | undefined, suffix: string): string { return `${this.baseUrl(req)}${this.scimPrefix}${suffix}`; } private authSchemes(): AuthScheme[] { const schemes: AuthScheme[] = []; if (this.loadedPlugins['authToken']) { schemes.push({ name: 'OAuth Bearer Token', description: 'Bearer token configured via --auth-token. Send "Authorization: Bearer ".', type: 'oauthbearertoken', primary: schemes.length === 0, }); } if (this.loadedPlugins['openidconnect']) { schemes.push({ name: 'OpenID Connect', description: 'OIDC-issued access token.', type: 'oauth2', specUri: 'https://openid.net/specs/openid-connect-core-1_0.html', primary: schemes.length === 0, }); } if (this.loadedPlugins['authHmac']) { schemes.push({ name: 'HMAC', description: 'Signed request via HMAC-SHA256.', type: 'httpbasic', primary: schemes.length === 0, }); } if (schemes.length === 0) { schemes.push({ name: 'No authentication', description: 'No authentication plugin loaded. NOT RECOMMENDED for production.', type: 'httpbasic', primary: true, }); } return schemes; } serviceProviderConfig(req?: DmRequest): ServiceProviderConfig { return { schemas: [SCHEMA_SERVICE_PROVIDER_CONFIG], patch: { supported: true }, bulk: { supported: true, maxOperations: (this.config.scim_bulk_max_operations as number) || 100, maxPayloadSize: (this.config.scim_bulk_max_payload_size as number) || 1048576, }, filter: { supported: true, maxResults: (this.config.scim_max_results as number) || 200, }, changePassword: { supported: false }, // Sorting is parsed for backwards compatibility but not consistently // applied server-side (see Users/Groups list handlers). We advertise // it as unsupported to avoid clients depending on stale guarantees. sort: { supported: false }, etag: { supported: Boolean(this.config.scim_etag) }, authenticationSchemes: this.authSchemes(), meta: { resourceType: 'ServiceProviderConfig', location: this.location(req, '/ServiceProviderConfig'), }, }; } resourceTypes(req?: DmRequest): ResourceTypeDefinition[] { return [ { schemas: [SCHEMA_RESOURCE_TYPE], id: 'User', name: 'User', endpoint: '/Users', description: 'User Account', schema: SCHEMA_USER, meta: { resourceType: 'ResourceType', location: this.location(req, '/ResourceTypes/User'), }, }, { schemas: [SCHEMA_RESOURCE_TYPE], id: 'Group', name: 'Group', endpoint: '/Groups', description: 'Group', schema: SCHEMA_GROUP, meta: { resourceType: 'ResourceType', location: this.location(req, '/ResourceTypes/Group'), }, }, ]; } resourceType( name: string, req?: DmRequest ): ResourceTypeDefinition | undefined { return this.resourceTypes(req).find(r => r.id === name); } schemas(req?: DmRequest): SchemaDefinition[] { const userSchema = this.loadSchema('User.json', SCHEMA_USER); const groupSchema = this.loadSchema('Group.json', SCHEMA_GROUP); const all = [userSchema, groupSchema].filter( (s): s is SchemaDefinition => s != null ); for (const s of all) { s.meta = { resourceType: 'Schema', location: this.location(req, `/Schemas/${s.id}`), }; } return all; } schema(id: string, req?: DmRequest): SchemaDefinition | undefined { return this.schemas(req).find(s => s.id === id); } private loadSchema( filename: string, urn: string ): SchemaDefinition | undefined { const filepath = path.join(this.schemaDir, filename); try { const content = fs.readFileSync(filepath, 'utf8'); const parsed = JSON.parse(content) as Partial; return { schemas: [SCHEMA_SCHEMA], id: urn, name: (parsed.name as string) || urn.split(':').pop() || urn, description: parsed.description, attributes: (parsed.attributes as unknown[]) || [], }; } catch { return undefined; } } } linagora-ldap-rest-16e557e/src/plugins/scim/errors.ts000066400000000000000000000114521522642357000226420ustar00rootroot00000000000000/** * @module plugins/scim/errors * @author Xavier Guimard * * SCIM 2.0 error envelope (RFC 7644 §3.12) and async handler wrapper. */ import type { Request, Response, NextFunction, RequestHandler } from 'express'; import { HttpError } from '../../lib/errors'; import { type ScimErrorResponse, type ScimErrorType, SCHEMA_ERROR, } from './types'; export const SCIM_CONTENT_TYPE = 'application/scim+json'; export class ScimError extends HttpError { scimType?: ScimErrorType; constructor(status: number, detail: string, scimType?: ScimErrorType) { super(detail, status); this.name = 'ScimError'; this.scimType = scimType; } } export const scimInvalidFilter = (detail = 'Invalid filter'): ScimError => new ScimError(400, detail, 'invalidFilter'); export const scimInvalidPath = (detail = 'Invalid path'): ScimError => new ScimError(400, detail, 'invalidPath'); export const scimInvalidValue = (detail = 'Invalid value'): ScimError => new ScimError(400, detail, 'invalidValue'); export const scimInvalidSyntax = (detail = 'Invalid syntax'): ScimError => new ScimError(400, detail, 'invalidSyntax'); export const scimNoTarget = (detail = 'No target'): ScimError => new ScimError(400, detail, 'noTarget'); export const scimMutability = (detail = 'Immutable attribute'): ScimError => new ScimError(400, detail, 'mutability'); export const scimUniqueness = (detail = 'Uniqueness violation'): ScimError => new ScimError(409, detail, 'uniqueness'); export const scimTooMany = (detail = 'Too many results'): ScimError => new ScimError(400, detail, 'tooMany'); export const scimNotFound = (detail = 'Resource not found'): ScimError => new ScimError(404, detail); export function writeScimError( res: Response, status: number, detail: string, scimType?: ScimErrorType ): void { const body: ScimErrorResponse = { schemas: [SCHEMA_ERROR], status: String(status), ...(scimType ? { scimType } : {}), detail, }; res.status(status).type(SCIM_CONTENT_TYPE).json(body); } /** * Extract an LDAP numeric error code from either a thrown ldapts error * (has `.code`) or an Error wrapped by ldapActions (message contains the * original text). Returns undefined if no code is detectable. */ export function extractLdapCode(err: unknown): number | undefined { if (err == null) return undefined; if (typeof err === 'object' && 'code' in err) { const code = (err as { code?: unknown }).code; if (typeof code === 'number') return code; } const msg = err instanceof Error ? err.message : typeof err === 'string' ? err : ''; if (/noSuchObject|No such object|code:?\s*(32|0x20)/i.test(msg)) return 32; if (/entryAlreadyExists|Already[_ ]?Exists|code:?\s*(68|0x44)/i.test(msg)) return 68; if (/noSuchAttribute|No such attribute|code:?\s*(16|0x10)/i.test(msg)) return 16; return undefined; } export function writeScimErrorFromException( res: Response, err: unknown, fallbackStatus = 500 ): void { // Authz plugins (e.g. core/auth/authzDynamic) embed a `[authz-forbidden]` // marker in the thrown message so a 403 can be recognised even after // intermediate callers wrap the error. Strip the marker before emitting // a client-facing message so the internal token never leaks. const sanitize = (s: string): string => /\[authz-forbidden\]/.test(s) ? 'Token does not have permission on this branch' : s; if (err instanceof ScimError) { writeScimError(res, err.statusCode, sanitize(err.message), err.scimType); return; } if (err instanceof HttpError) { writeScimError(res, err.statusCode, sanitize(err.message)); return; } const message = err instanceof Error ? err.message : String(err); // Wrapped authz-forbidden (no HttpError instance but marker is in the msg) if (/\[authz-forbidden\]/.test(message)) { writeScimError(res, 403, 'Token does not have permission on this branch'); return; } const ldapCode = extractLdapCode(err); if (ldapCode === 32) { writeScimError(res, 404, 'Resource not found'); return; } if (ldapCode === 68) { writeScimError(res, 409, sanitize(message), 'uniqueness'); return; } writeScimError(res, fallbackStatus, sanitize(message) || 'Internal error'); } /** * Express wrapper that catches errors, serializes them as SCIM * envelope and short-circuits the default Express error middleware. * Accepts both sync and async handlers. */ export const scimAsyncHandler = ( fn: (req: Request, res: Response, next: NextFunction) => void | Promise ): RequestHandler => { return (req: Request, res: Response, next: NextFunction) => { Promise.resolve() .then(() => fn(req, res, next)) .catch(err => { if (!res.headersSent) { writeScimErrorFromException(res, err); } else { next(err); } }); }; }; linagora-ldap-rest-16e557e/src/plugins/scim/filter.ts000066400000000000000000000264671522642357000226270ustar00rootroot00000000000000/** * @module plugins/scim/filter * @author Xavier Guimard * * SCIM 2.0 filter parser (RFC 7644 §3.4.2.2) → LDAP filter (RFC 4515). * * Grammar (simplified): * filter = logicExpr * logicExpr = notExpr (('and'|'or') notExpr)* * notExpr = 'not' ? primary * primary = '(' logicExpr ')' * | attrPath complexOp '[' logicExpr ']' * | attrPath compareOp value * | attrPath 'pr' * attrPath = ATTR ('.' SUBATTR)? * compareOp = 'eq' | 'ne' | 'co' | 'sw' | 'ew' | 'gt' | 'ge' | 'lt' | 'le' * value = STRING | NUMBER | 'true' | 'false' | 'null' * * All string values are escaped via escapeLdapFilter() before emission. * Unknown SCIM attribute paths cause a ScimError(invalidFilter, 400). */ import { escapeLdapFilter } from '../../lib/utils'; import { scimPathToLdapAttribute } from './mapping'; import type { ResourceMapping } from './types'; import { scimInvalidFilter } from './errors'; type TokenKind = | 'LPAREN' | 'RPAREN' | 'LBRACK' | 'RBRACK' | 'WORD' | 'STRING' | 'NUMBER' | 'EOF'; interface Token { kind: TokenKind; value: string; pos: number; } const KEYWORDS = new Set([ 'and', 'or', 'not', 'pr', 'eq', 'ne', 'co', 'sw', 'ew', 'gt', 'ge', 'lt', 'le', 'true', 'false', 'null', ]); function tokenize(input: string): Token[] { const tokens: Token[] = []; let i = 0; while (i < input.length) { const c = input[i]; if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; } if (c === '(') { tokens.push({ kind: 'LPAREN', value: '(', pos: i }); i++; continue; } if (c === ')') { tokens.push({ kind: 'RPAREN', value: ')', pos: i }); i++; continue; } if (c === '[') { tokens.push({ kind: 'LBRACK', value: '[', pos: i }); i++; continue; } if (c === ']') { tokens.push({ kind: 'RBRACK', value: ']', pos: i }); i++; continue; } if (c === '"') { // String literal, with \" and \\ escapes const start = i; i++; let value = ''; while (i < input.length && input[i] !== '"') { if (input[i] === '\\' && i + 1 < input.length) { const next = input[i + 1]; if (next === '"') { value += '"'; i += 2; continue; } if (next === '\\') { value += '\\'; i += 2; continue; } if (next === 'n') { value += '\n'; i += 2; continue; } if (next === 't') { value += '\t'; i += 2; continue; } } value += input[i]; i++; } if (i >= input.length) { throw scimInvalidFilter( `Unterminated string literal at position ${start}` ); } i++; // skip closing " tokens.push({ kind: 'STRING', value, pos: start }); continue; } if (c >= '0' && c <= '9') { const start = i; while ( i < input.length && /[0-9.+\-eE]/.test(input[i]) && !/[\s()[\]]/.test(input[i]) ) { i++; } tokens.push({ kind: 'NUMBER', value: input.slice(start, i), pos: start }); continue; } // Word: identifier, keyword, or dotted path if (/[A-Za-z_$:]/.test(c)) { const start = i; while (i < input.length && /[A-Za-z0-9_.$:]/.test(input[i])) { i++; } tokens.push({ kind: 'WORD', value: input.slice(start, i), pos: start }); continue; } throw scimInvalidFilter(`Unexpected character '${c}' at position ${i}`); } tokens.push({ kind: 'EOF', value: '', pos: input.length }); return tokens; } class Parser { private pos = 0; constructor( private tokens: Token[], private mapping: ResourceMapping ) {} private peek(offset = 0): Token { return this.tokens[this.pos + offset]; } private consume(): Token { return this.tokens[this.pos++]; } private expect(kind: TokenKind, value?: string): Token { const tok = this.tokens[this.pos]; if (tok.kind !== kind || (value && tok.value.toLowerCase() !== value)) { throw scimInvalidFilter( `Expected ${value || kind} at position ${tok.pos}, got '${tok.value}'` ); } this.pos++; return tok; } parse(): string { const result = this.parseOr(); if (this.peek().kind !== 'EOF') { throw scimInvalidFilter( `Unexpected token '${this.peek().value}' at position ${this.peek().pos}` ); } return result; } private parseOr(): string { const parts: string[] = [this.parseAnd()]; while ( this.peek().kind === 'WORD' && this.peek().value.toLowerCase() === 'or' ) { this.consume(); parts.push(this.parseAnd()); } if (parts.length === 1) return parts[0]; return `(|${parts.join('')})`; } private parseAnd(): string { const parts: string[] = [this.parseNot()]; while ( this.peek().kind === 'WORD' && this.peek().value.toLowerCase() === 'and' ) { this.consume(); parts.push(this.parseNot()); } if (parts.length === 1) return parts[0]; return `(&${parts.join('')})`; } private parseNot(): string { if ( this.peek().kind === 'WORD' && this.peek().value.toLowerCase() === 'not' ) { this.consume(); const expr = this.parsePrimary(); return `(!${expr})`; } return this.parsePrimary(); } private parsePrimary(): string { const tok = this.peek(); if (tok.kind === 'LPAREN') { this.consume(); const inner = this.parseOr(); this.expect('RPAREN'); return inner; } if (tok.kind === 'WORD') { // attrPath const attrTok = this.consume(); const path = attrTok.value; // Complex multi-valued: path '[' filter ']' // These require sub-attribute scope binding (e.g. emails[value eq "x"] // should constrain 'value' to the same email entry as the primary // match). We do not implement that yet — rejecting upfront is safer // than silently applying the filter to the wrong attribute. if (this.peek().kind === 'LBRACK') { throw scimInvalidFilter( `Complex multi-valued filters ('${path}[...]') are not supported; use 'and' at top level instead` ); } const op = this.consume(); if (op.kind !== 'WORD') { throw scimInvalidFilter( `Expected comparison operator at position ${op.pos}` ); } const opName = op.value.toLowerCase(); if (opName === 'pr') { const ldapAttr = this.resolvePath(path); return `(${ldapAttr}=*)`; } if (!KEYWORDS.has(opName) || ['and', 'or', 'not'].includes(opName)) { throw scimInvalidFilter( `Unknown operator '${op.value}' at position ${op.pos}` ); } const valueTok = this.consume(); const value = this.tokenToValue(valueTok); return this.emitComparison(path, opName, value); } throw scimInvalidFilter( `Unexpected token '${tok.value}' at position ${tok.pos}` ); } private tokenToValue(tok: Token): string | number | boolean | null { if (tok.kind === 'STRING') return tok.value; if (tok.kind === 'NUMBER') { const n = Number(tok.value); if (Number.isNaN(n)) { throw scimInvalidFilter( `Invalid number '${tok.value}' at position ${tok.pos}` ); } return n; } if (tok.kind === 'WORD') { const v = tok.value.toLowerCase(); if (v === 'true') return true; if (v === 'false') return false; if (v === 'null') return null; // Bare word value — treat as string return tok.value; } throw scimInvalidFilter(`Unexpected value token at position ${tok.pos}`); } private resolvePath(path: string): string { // `id` is only supported via the top-level short-circuit `id eq "..."` // handled by `scimFilterToLdap()`. Any other use would leak as a literal // `id` into the emitted LDAP filter. if (path === 'id') { throw scimInvalidFilter( "filter on 'id' only supports 'eq' with a string value" ); } // 'active' → presence of pwdAccountLockedTime (emitted in emitComparison) if (path === 'active') return 'active'; const ldapAttr = scimPathToLdapAttribute(path, this.mapping); if (!ldapAttr) { throw scimInvalidFilter(`Unknown attribute path '${path}'`); } return ldapAttr; } private emitComparison( path: string, op: string, value: string | number | boolean | null ): string { const ldapAttr = this.resolvePath(path); // Pseudo-attributes if (ldapAttr === 'active') { const truthy = value === true || (typeof value === 'string' && value.toLowerCase() === 'true'); // active=true <=> no pwdAccountLockedTime // active=false <=> pwdAccountLockedTime present if (op === 'eq') return truthy ? '(!(pwdAccountLockedTime=*))' : '(pwdAccountLockedTime=*)'; if (op === 'ne') return truthy ? '(pwdAccountLockedTime=*)' : '(!(pwdAccountLockedTime=*))'; throw scimInvalidFilter(`Operator '${op}' not supported for 'active'`); } if (value === null) { if (op === 'eq') return `(!(${ldapAttr}=*))`; if (op === 'ne') return `(${ldapAttr}=*)`; throw scimInvalidFilter(`Operator '${op}' not supported with null`); } const strValue = String(value); const esc = escapeLdapFilter(strValue); switch (op) { case 'eq': return `(${ldapAttr}=${esc})`; case 'ne': return `(!(${ldapAttr}=${esc}))`; case 'co': return `(${ldapAttr}=*${esc}*)`; case 'sw': return `(${ldapAttr}=${esc}*)`; case 'ew': return `(${ldapAttr}=*${esc})`; case 'gt': return `(&(${ldapAttr}>=${esc})(!(${ldapAttr}=${esc})))`; case 'ge': return `(${ldapAttr}>=${esc})`; case 'lt': return `(&(${ldapAttr}<=${esc})(!(${ldapAttr}=${esc})))`; case 'le': return `(${ldapAttr}<=${esc})`; default: throw scimInvalidFilter(`Unknown operator '${op}'`); } } } export interface TranslatedFilter { ldapFilter: string; /** True if the filter contains `id eq "..."`, which the caller should translate into a DN lookup. */ touchesId: boolean; /** Extracted id value if the filter is a simple `id eq "..."` (single clause). */ idEquals?: string; } /** * Translate a SCIM filter string into an LDAP filter. * Returns metadata enabling the caller to short-circuit id-equals queries. */ export function scimFilterToLdap( filter: string, mapping: ResourceMapping ): TranslatedFilter { const trimmed = filter.trim(); if (!trimmed) { return { ldapFilter: '(objectClass=*)', touchesId: false }; } // Detect a simple `id eq "value"` first (very common from clients) const simple = /^id\s+eq\s+"([^"\\]*(?:\\.[^"\\]*)*)"\s*$/i.exec(trimmed); if (simple) { return { ldapFilter: '(objectClass=*)', touchesId: true, idEquals: simple[1].replace(/\\(.)/g, '$1'), }; } const tokens = tokenize(trimmed); const parser = new Parser(tokens, mapping); const ldapFilter = parser.parse(); return { ldapFilter, touchesId: /\bid\b/.test(trimmed) }; } linagora-ldap-rest-16e557e/src/plugins/scim/groups.ts000066400000000000000000000362411522642357000226500ustar00rootroot00000000000000/** * @module plugins/scim/groups * @author Xavier Guimard * * SCIM Groups resource handler — direct access via ldapActions. */ import type winston from 'winston'; import type ldapActions from '../../lib/ldapActions'; import type { AttributesList, ModifyRequest, SearchResult, } from '../../lib/ldapActions'; import type { Config } from '../../config/args'; import type { DmRequest } from '../../lib/auth/base'; import { escapeDnValue, escapeLdapFilter, isChildOf, launchHooks, launchHooksChained, validateDnValue, } from '../../lib/utils'; import { BaseResolver } from './baseResolver'; import { type ResourceMapping, type ScimGroup, type ListResponse, type PatchRequest, type MultiValued, SCHEMA_LIST_RESPONSE, } from './types'; import { DEFAULT_GROUP_MAPPING, loadMappingFile, mergeMapping, ldapToScimGroup, scimGroupToLdap, requiredLdapAttributes, type MappingContext, } from './mapping'; import { scimFilterToLdap } from './filter'; import { patchToModifyRequest } from './patch'; import { scimInvalidValue, scimNotFound, scimTooMany, scimUniqueness, ScimError, extractLdapCode, } from './errors'; import type { ScimUsers } from './users'; export interface GroupListQuery { filter?: string; startIndex?: number; count?: number; // sortBy / sortOrder accepted for parser compatibility but not honoured // (ServiceProviderConfig advertises sort.supported = false). sortBy?: string; sortOrder?: 'ascending' | 'descending'; } export interface ScimGroupsOptions { ldap: ldapActions; config: Config; logger: winston.Logger; baseResolver: BaseResolver; users: ScimUsers; // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type hooks: { [K: string]: Function[] | undefined }; } export class ScimGroups { private readonly ldap: ldapActions; private readonly config: Config; private readonly logger: winston.Logger; private readonly baseResolver: BaseResolver; private readonly users: ScimUsers; // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type private readonly hooks: { [K: string]: Function[] | undefined }; private readonly mapping: ResourceMapping; private readonly rdnAttribute: string; private readonly objectClass: string[]; private readonly idAttribute: string; private readonly maxResults: number; private readonly scimPrefix: string; constructor(opts: ScimGroupsOptions) { this.ldap = opts.ldap; this.config = opts.config; this.logger = opts.logger; this.baseResolver = opts.baseResolver; this.users = opts.users; this.hooks = opts.hooks; this.rdnAttribute = (this.config.scim_group_rdn_attribute as string) || 'cn'; this.objectClass = this.config.scim_group_object_class as string[]; this.idAttribute = (this.config.scim_id_attribute as string) || 'rdn'; this.maxResults = (this.config.scim_max_results as number) || 200; this.scimPrefix = (this.config.scim_prefix as string) || '/scim/v2'; const override = (this.config.scim_group_mapping as string) || ''; this.mapping = mergeMapping( DEFAULT_GROUP_MAPPING, override ? loadMappingFile(override) : undefined ); } private ctx(req?: DmRequest): MappingContext { return { idAttribute: this.idAttribute, rdnAttribute: this.rdnAttribute, resourceType: 'Group', baseUrl: (this.config.scim_base_url as string) || (req?.protocol && req.get ? `${req.protocol}://${String(req.get('host') || '')}` : ''), scimPrefix: this.scimPrefix, }; } private dnForId(id: string, req?: DmRequest): string { const base = this.baseResolver.groupBase(req); return `${this.rdnAttribute}=${escapeDnValue(id)},${base}`; } /** * Resolve an LDAP member DN back to a SCIM reference (for GET responses). * Best-effort: extract the RDN value and a guessed type (User if in user base). */ private buildMemberResolver( req: DmRequest | undefined ): (dn: string) => MultiValued | undefined { const userBase = this.baseResolver.userBase(req); const groupBase = this.baseResolver.groupBase(req); const scimPrefix = this.scimPrefix; const baseUrl = (this.config.scim_base_url as string) || (req?.protocol && req.get ? `${req.protocol}://${String(req.get('host') || '')}` : ''); const userRdn = (this.config.scim_user_rdn_attribute as string) || 'uid'; const groupRdn = this.rdnAttribute; const placeholder = (this.config.group_dummy_user as string) || 'cn=fakeuser'; return (dn: string): MultiValued | undefined => { if (!dn) return undefined; // Hide the schema-placeholder member from SCIM responses if (dn.toLowerCase() === placeholder.toLowerCase()) return undefined; // Extract RDN value from DN const rdnMatch = /^([^=]+)=((?:\\.|[^,])+)/.exec(dn); if (!rdnMatch) return undefined; const rdnValue = rdnMatch[2].replace(/\\(.)/g, '$1'); const lower = dn.toLowerCase(); let type: 'User' | 'Group' | undefined; if (userBase && lower.endsWith(userBase.toLowerCase())) type = 'User'; else if (groupBase && lower.endsWith(groupBase.toLowerCase())) type = 'Group'; else if (rdnMatch[1] === userRdn) type = 'User'; else if (rdnMatch[1] === groupRdn) type = 'Group'; const result: MultiValued = { value: rdnValue }; if (type) { result.type = type; if (baseUrl) { const endpoint = type === 'User' ? 'Users' : 'Groups'; result.$ref = `${baseUrl.replace(/\/$/, '')}${scimPrefix}/${endpoint}/${encodeURIComponent(rdnValue)}`; } } return result; }; } async get(req: DmRequest, id: string): Promise { const dn = this.dnForId(id, req); let result: SearchResult; try { result = (await this.ldap.search( { paged: false, scope: 'base', attributes: [...requiredLdapAttributes(this.mapping), 'member'], }, dn )) as SearchResult; } catch (err) { if (extractLdapCode(err) === 32) { throw scimNotFound(`Group ${id} not found`); } throw err; } if (!result.searchEntries || result.searchEntries.length === 0) { throw scimNotFound(`Group ${id} not found`); } return ldapToScimGroup( result.searchEntries[0] as AttributesList, this.mapping, this.ctx(req), this.buildMemberResolver(req) ); } async list( req: DmRequest, query: GroupListQuery ): Promise> { const base = this.baseResolver.groupBase(req); const startIndex = Math.max(1, query.startIndex || 1); const count = Math.min( this.maxResults, Math.max(0, query.count ?? this.maxResults) ); let ldapFilter = `(objectClass=${this.objectClass.find(c => c !== 'top') || 'groupOfNames'})`; let idEquals: string | undefined; if (query.filter) { const translated = scimFilterToLdap(query.filter, this.mapping); if (translated.idEquals) { idEquals = translated.idEquals; } else { ldapFilter = `(&${ldapFilter}${translated.ldapFilter})`; } } if (idEquals) { try { const group = await this.get(req, idEquals); return { schemas: [SCHEMA_LIST_RESPONSE], totalResults: 1, startIndex: 1, itemsPerPage: 1, Resources: [group], }; } catch (err) { if (err instanceof ScimError && err.statusCode === 404) { return { schemas: [SCHEMA_LIST_RESPONSE], totalResults: 0, startIndex: 1, itemsPerPage: 0, Resources: [], }; } throw err; } } const result = (await this.ldap.search( { filter: ldapFilter, scope: 'sub', paged: false, attributes: [...requiredLdapAttributes(this.mapping), 'member'], sizeLimit: this.maxResults + 1, }, base )) as SearchResult; const entries = result.searchEntries || []; const total = entries.length; if (total > this.maxResults) { throw scimTooMany( `Filter matched ${total} resources, exceeds max ${this.maxResults}` ); } const page = entries.slice(startIndex - 1, startIndex - 1 + count); const resolver = this.buildMemberResolver(req); const resources = page.map(e => ldapToScimGroup( e as AttributesList, this.mapping, this.ctx(req), resolver ) ); return { schemas: [SCHEMA_LIST_RESPONSE], totalResults: total, startIndex, itemsPerPage: resources.length, Resources: resources, }; } async create(req: DmRequest, resource: ScimGroup): Promise { if (!resource.displayName) { throw scimInvalidValue('displayName is required'); } const hookInput = await launchHooksChained(this.hooks.scimgroupcreate, [ resource, req, ] as [ScimGroup, DmRequest]); const group = hookInput[0]; const { rdn, attributes } = scimGroupToLdap( group, this.mapping, this.objectClass ); if (!rdn) throw scimInvalidValue('displayName is required'); validateDnValue(rdn, this.rdnAttribute); attributes[this.rdnAttribute] = rdn; // Resolve members (SCIM value → LDAP DN) const memberDns: string[] = []; if (group.members && Array.isArray(group.members)) { for (const m of group.members) { if (!m || !m.value) continue; const dn = await this.users.resolveRef(req, m.value); if (dn) memberDns.push(dn); } } // groupOfNames requires at least one member if (memberDns.length === 0) { // Fall back to a placeholder; mirrors --group-dummy-user convention const placeholder = (this.config.group_dummy_user as string) || 'cn=fakeuser'; memberDns.push(placeholder); } attributes.member = memberDns; const base = this.baseResolver.groupBase(req); const dn = `${this.rdnAttribute}=${escapeDnValue(rdn)},${base}`; try { await this.ldap.add(dn, attributes, req); } catch (err) { if (extractLdapCode(err) === 68) { throw scimUniqueness(`Group ${rdn} already exists`); } throw err; } const created = await this.get(req, rdn); void launchHooks(this.hooks.scimgroupcreatedone, created); return created; } async replace( req: DmRequest, id: string, resource: ScimGroup ): Promise { await this.get(req, id); // ensure exists const hookInput = await launchHooksChained(this.hooks.scimgroupupdate, [ id, resource, req, ] as [string, ScimGroup, DmRequest]); const incoming = hookInput[1]; const { attributes } = scimGroupToLdap( incoming, this.mapping, this.objectClass ); const memberDns: string[] = []; if (incoming.members && Array.isArray(incoming.members)) { for (const m of incoming.members) { if (!m || !m.value) continue; const dn = await this.users.resolveRef(req, m.value); if (dn) memberDns.push(dn); } } if (memberDns.length === 0) { const placeholder = (this.config.group_dummy_user as string) || 'cn=fakeuser'; memberDns.push(placeholder); } const dn = this.dnForId(id, req); const changes: ModifyRequest = { replace: {} }; for (const [k, v] of Object.entries(attributes)) { if (k === 'objectClass' || k === this.rdnAttribute) continue; changes.replace![k] = v; } changes.replace!.member = memberDns; await this.ldap.modify(dn, changes, req); const updated = await this.get(req, id); void launchHooks(this.hooks.scimgroupupdatedone, id, updated); return updated; } async patch( req: DmRequest, id: string, patch: PatchRequest ): Promise { await this.get(req, id); // ensure exists const changes = await patchToModifyRequest(patch, { mapping: this.mapping, memberAttribute: 'member', resolveMemberRef: async value => this.users.resolveRef(req, value), }); if ( !changes.add && !changes.replace && (!changes.delete || (Array.isArray(changes.delete) && changes.delete.length === 0)) ) { return this.get(req, id); } // groupOfNames requires at least one member: if a member-remove would empty // the attribute, add back the configured placeholder to satisfy the schema. const placeholder = (this.config.group_dummy_user as string) || 'cn=fakeuser'; const dn = this.dnForId(id, req); if ( changes.delete && !Array.isArray(changes.delete) && changes.delete.member ) { const current = (await this.ldap.search( { paged: false, scope: 'base', attributes: ['member'] }, dn )) as SearchResult; const currentMembers = current.searchEntries[0]?.member; const currentArr = Array.isArray(currentMembers) ? (currentMembers as string[]) : currentMembers ? [String(currentMembers)] : []; const toDelete = Array.isArray(changes.delete.member) ? (changes.delete.member as string[]) : [String(changes.delete.member)]; const remaining = currentArr.filter(m => !toDelete.includes(m)); if (remaining.length === 0) { if (!changes.add) changes.add = {}; changes.add.member = [placeholder]; } } await this.ldap.modify(dn, changes, req); const updated = await this.get(req, id); void launchHooks(this.hooks.scimgroupupdatedone, id, updated); return updated; } async delete(req: DmRequest, id: string): Promise { await this.get(req, id); // ensure exists const hookInput = await launchHooksChained(this.hooks.scimgroupdelete, [ id, req, ] as [string, DmRequest]); const finalId = hookInput[0]; const dn = this.dnForId(finalId, req); await this.ldap.delete(dn, req); void launchHooks(this.hooks.scimgroupdeletedone, finalId); } /** Used internally and by Bulk to resolve a Group SCIM reference to a DN. */ async resolveRef(req: DmRequest, value: string): Promise { if (!value) return undefined; const base = this.baseResolver.groupBase(req); if (value.includes('=') && value.includes(',')) { if (isChildOf(value, base)) return value; return undefined; } const dn = `${this.rdnAttribute}=${escapeDnValue(value)},${base}`; try { const res = (await this.ldap.search( { paged: false, scope: 'base', attributes: ['dn'] }, dn )) as SearchResult; if (res.searchEntries && res.searchEntries.length > 0) { return res.searchEntries[0].dn; } } catch { // not found directly } try { const res = (await this.ldap.search( { filter: `(${this.rdnAttribute}=${escapeLdapFilter(value)})`, scope: 'sub', paged: false, attributes: ['dn'], }, base )) as SearchResult; if (res.searchEntries && res.searchEntries.length > 0) { return res.searchEntries[0].dn; } } catch { // ignore } return undefined; } get groupMapping(): ResourceMapping { return this.mapping; } } linagora-ldap-rest-16e557e/src/plugins/scim/mapping.ts000066400000000000000000000263411522642357000227640ustar00rootroot00000000000000/** * @module plugins/scim/mapping * @author Xavier Guimard * * Bidirectional mapping between LDAP entries and SCIM resources. * * Default mappings target inetOrgPerson (User) and groupOfNames (Group), * consistent with static/schemas/standard/users.json role semantics. * * Custom mappings can be loaded from JSON via --scim-user-mapping / --scim-group-mapping. */ import fs from 'fs'; import type { AttributesList, AttributeValue } from '../../lib/ldapActions'; import { type ScimUser, type ScimGroup, type MappingEntry, type ResourceMapping, type MultiValued, SCHEMA_USER, SCHEMA_GROUP, } from './types'; const OPERATIONAL_ATTRIBUTES = [ 'createTimestamp', 'modifyTimestamp', 'entryUUID', ]; export const DEFAULT_USER_MAPPING: ResourceMapping = { resourceType: 'User', schemas: [SCHEMA_USER], entries: [ { scim: 'userName', ldap: 'uid' }, { scim: 'externalId', ldap: 'employeeNumber' }, { scim: 'name', sub: { familyName: 'sn', givenName: 'givenName', formatted: 'cn', middleName: 'initials', }, }, { scim: 'displayName', ldap: 'displayName' }, { scim: 'nickName', ldap: 'displayName' }, { scim: 'title', ldap: 'title' }, { scim: 'preferredLanguage', ldap: 'preferredLanguage' }, { scim: 'emails', ldapPrimary: 'mail', ldapSecondary: 'mailAlternateAddress', multi: 'array', }, { scim: 'phoneNumbers', ldapPrimary: 'telephoneNumber', ldapSecondary: 'mobile', multi: 'array', }, ], }; export const DEFAULT_GROUP_MAPPING: ResourceMapping = { resourceType: 'Group', schemas: [SCHEMA_GROUP], entries: [ { scim: 'displayName', ldap: 'cn' }, { scim: 'externalId', ldap: 'entryUUID', operational: true, readOnly: true, }, ], }; function asString(v: AttributeValue | undefined): string | undefined { if (v == null) return undefined; if (Array.isArray(v)) { const first = v[0]; return first == null ? undefined : String(first); } if (Buffer.isBuffer(v)) return v.toString(); return String(v); } function asArray(v: AttributeValue | undefined): string[] { if (v == null) return []; if (Array.isArray(v)) { return v.map(x => (Buffer.isBuffer(x) ? x.toString() : String(x))); } if (Buffer.isBuffer(v)) return [v.toString()]; return [String(v)]; } export function loadMappingFile(path: string): ResourceMapping | undefined { if (!path) return undefined; const content = fs.readFileSync(path, 'utf8'); return JSON.parse(content) as ResourceMapping; } /** * Build the mapping used at runtime by merging a user-supplied JSON * override on top of the default. Override entries replace default * entries with the same `scim` key; new entries are appended. */ export function mergeMapping( base: ResourceMapping, override?: ResourceMapping ): ResourceMapping { if (!override) return base; const map = new Map(); for (const e of base.entries) map.set(e.scim, e); for (const e of override.entries) map.set(e.scim, e); return { ...base, entries: Array.from(map.values()) }; } export interface MappingContext { /** 'rdn' (default), 'entryUUID', or any LDAP attribute name */ idAttribute: string; rdnAttribute: string; resourceType: 'User' | 'Group'; baseUrl?: string; scimPrefix: string; } /** Resolve the SCIM id for an LDAP entry, per configuration. */ export function resolveScimId( entry: AttributesList, ctx: MappingContext ): string | undefined { if (ctx.idAttribute === 'rdn') { return asString(entry[ctx.rdnAttribute]); } const v = asString(entry[ctx.idAttribute]); return v ?? asString(entry[ctx.rdnAttribute]); } export function buildLocation( id: string, ctx: MappingContext ): string | undefined { if (!ctx.baseUrl) return undefined; const endpoint = ctx.resourceType === 'User' ? 'Users' : 'Groups'; return `${ctx.baseUrl.replace(/\/$/, '')}${ctx.scimPrefix}/${endpoint}/${encodeURIComponent(id)}`; } export function ldapToScimUser( entry: AttributesList, mapping: ResourceMapping, ctx: MappingContext ): ScimUser { const out: ScimUser = { schemas: [SCHEMA_USER] }; const id = resolveScimId(entry, ctx); if (id) out.id = id; for (const m of mapping.entries) { if (m.sub) { const sub: Record = {}; for (const [scimKey, ldapKey] of Object.entries(m.sub)) { const v = asString(entry[ldapKey]); if (v != null) sub[scimKey] = v; } if (Object.keys(sub).length > 0) { (out as Record)[m.scim] = sub; } continue; } if (m.ldapPrimary || m.ldapSecondary) { const arr: MultiValued[] = []; if (m.ldapPrimary) { const primary = asString(entry[m.ldapPrimary]); if (primary != null) arr.push({ value: primary, primary: true }); } if (m.ldapSecondary) { for (const v of asArray(entry[m.ldapSecondary])) { arr.push({ value: v }); } } if (arr.length > 0) (out as Record)[m.scim] = arr; continue; } if (m.ldap) { if (m.multi === 'array') { const arr = asArray(entry[m.ldap]); if (arr.length > 0) (out as Record)[m.scim] = arr; } else { const v = asString(entry[m.ldap]); if (v != null) (out as Record)[m.scim] = v; } } } // active: true if pwdAccountLockedTime is not set (reasonable default) out.active = entry['pwdAccountLockedTime'] == null; // meta if (id) { out.meta = { resourceType: 'User', created: asString(entry['createTimestamp']), lastModified: asString(entry['modifyTimestamp']), location: buildLocation(id, ctx), }; } return out; } /** * Convert SCIM User body → LDAP attributes list. * The RDN attribute is set from `userName` (or explicit `id`). * Returns { rdn, attributes } so the caller can build the DN. */ export function scimUserToLdap( user: ScimUser, mapping: ResourceMapping, ctx: MappingContext, objectClass: string[] ): { rdn: string; attributes: AttributesList } { const attributes: AttributesList = { objectClass }; const rdnValue = user.userName || user.id || ''; for (const m of mapping.entries) { if (m.readOnly || m.operational) continue; const value = (user as Record)[m.scim]; if (value == null) continue; if (m.sub && typeof value === 'object') { for (const [scimKey, ldapKey] of Object.entries(m.sub)) { const sv = (value as Record)[scimKey]; if (typeof sv === 'string' && sv.length > 0) attributes[ldapKey] = sv; } continue; } if (m.ldapPrimary || m.ldapSecondary) { if (!Array.isArray(value)) continue; const mv = value as MultiValued[]; const primary = mv.find(v => v.primary === true) || mv[0]; const others = mv.filter(v => v !== primary); if (m.ldapPrimary && primary && primary.value) { attributes[m.ldapPrimary] = primary.value; } if (m.ldapSecondary && others.length > 0) { attributes[m.ldapSecondary] = others.map(v => v.value).filter(Boolean); } continue; } if (m.ldap) { if (Array.isArray(value)) { attributes[m.ldap] = value.map(v => String(v)); } else if (typeof value === 'string' || typeof value === 'number') { attributes[m.ldap] = String(value); } } } // Ensure required inetOrgPerson attributes have sensible defaults if (!attributes.cn && rdnValue) attributes.cn = rdnValue; if (!attributes.sn) { const sn = user.name?.familyName || user.displayName || rdnValue; if (sn) attributes.sn = sn; } // Set RDN attribute value explicitly return { rdn: rdnValue, attributes }; } export function ldapToScimGroup( entry: AttributesList, mapping: ResourceMapping, ctx: MappingContext, memberResolver?: (dn: string) => MultiValued | undefined ): ScimGroup { const out: ScimGroup = { schemas: [SCHEMA_GROUP] }; const id = resolveScimId(entry, ctx); if (id) out.id = id; for (const m of mapping.entries) { if (!m.ldap) continue; const raw = entry[m.ldap]; if (raw == null) continue; if (m.multi === 'array') { const arr = asArray(raw); if (arr.length > 0) (out as Record)[m.scim] = arr; } else { const v = asString(raw); if (v != null) (out as Record)[m.scim] = v; } } // Resolve members from LDAP `member` DN list const memberDns = asArray(entry['member']); const members: MultiValued[] = []; for (const dn of memberDns) { if (!dn) continue; if (memberResolver) { const resolved = memberResolver(dn); if (resolved) members.push(resolved); } else { members.push({ value: dn, type: 'User' }); } } if (members.length > 0) out.members = members; if (id) { out.meta = { resourceType: 'Group', created: asString(entry['createTimestamp']), lastModified: asString(entry['modifyTimestamp']), location: buildLocation(id, ctx), }; } return out; } /** * Convert SCIM Group → LDAP attributes (members excluded; they're * resolved separately to DNs by the handler). */ export function scimGroupToLdap( group: ScimGroup, mapping: ResourceMapping, objectClass: string[] ): { rdn: string; attributes: AttributesList } { const attributes: AttributesList = { objectClass }; const rdnValue = group.displayName || group.id || ''; for (const m of mapping.entries) { if (m.readOnly || m.operational) continue; if (!m.ldap) continue; const v = (group as Record)[m.scim]; if (v == null) continue; if (Array.isArray(v)) { attributes[m.ldap] = v.map(x => typeof x === 'string' ? x : JSON.stringify(x) ); } else if (typeof v === 'string' || typeof v === 'number') { attributes[m.ldap] = String(v); } } return { rdn: rdnValue, attributes }; } /** * List of LDAP attributes to request from the directory so all * mapped SCIM attributes can be populated. */ export function requiredLdapAttributes(mapping: ResourceMapping): string[] { const attrs = new Set(['objectClass', ...OPERATIONAL_ATTRIBUTES]); for (const m of mapping.entries) { if (m.ldap) attrs.add(m.ldap); if (m.ldapPrimary) attrs.add(m.ldapPrimary); if (m.ldapSecondary) attrs.add(m.ldapSecondary); if (m.sub) for (const v of Object.values(m.sub)) attrs.add(v); } return Array.from(attrs); } /** * Given a SCIM attribute path like "emails.value" or "name.familyName", * return the corresponding LDAP attribute name (used by filter parser). * Returns undefined if the path is not mapped. */ export function scimPathToLdapAttribute( path: string, mapping: ResourceMapping ): string | undefined { // Special: id, userName, displayName, active const top = path.split('.')[0]; const sub = path.includes('.') ? path.split('.').slice(1).join('.') : ''; for (const m of mapping.entries) { if (m.scim !== top) continue; if (m.sub && sub) { return m.sub[sub]; } // Multi-valued: emails.value → primary attr if ((m.ldapPrimary || m.ldapSecondary) && (sub === 'value' || !sub)) { return m.ldapPrimary || m.ldapSecondary; } if (m.ldap) return m.ldap; } return undefined; } linagora-ldap-rest-16e557e/src/plugins/scim/patch.ts000066400000000000000000000301641522642357000224260ustar00rootroot00000000000000/** * @module plugins/scim/patch * @author Xavier Guimard * * SCIM PatchOp applicator (RFC 7644 §3.5.2) → ldapts ModifyRequest. * * Supports: * - op: add / remove / replace * - simple paths: "displayName", "userName" * - sub-attribute paths: "name.familyName" * - multi-valued paths: "emails" * - Group member ops via filtered path: `members[value eq "alice"]` * - implicit path (op.value is an object) * * Complex filtered paths on multi-valued attributes OTHER than `members` * (e.g. `emails[type eq "work"]`) are rejected with `invalidPath`: the * plugin only knows how to map them for member-type resolution. Filtered * member operations for Groups are handled via the caller-provided * `resolveMemberRef` hook (SCIM id → LDAP DN lookup). */ import type { AttributesList, AttributeValue, ModifyRequest, } from '../../lib/ldapActions'; import { scimInvalidPath, scimNoTarget, scimInvalidValue } from './errors'; import { type ResourceMapping, type PatchOperation, type PatchRequest, } from './types'; import { scimPathToLdapAttribute } from './mapping'; export interface PatchContext { mapping: ResourceMapping; /** For Groups: resolve SCIM member value (id or $ref) → LDAP DN. Async. */ resolveMemberRef?: (value: string) => Promise; /** The LDAP attribute holding members, default 'member'. */ memberAttribute?: string; } function normalizeOp(op: string): 'add' | 'remove' | 'replace' { const lower = op.toLowerCase(); if (lower === 'add' || lower === 'remove' || lower === 'replace') return lower; throw scimInvalidValue(`Unknown patch op '${op}'`); } const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); const ATTR_NAME_RE = /^[A-Za-z_$:][\w$:]*$/; function assertSafeKey(key: string): void { if (FORBIDDEN_KEYS.has(key)) { throw scimInvalidPath(`Forbidden attribute name '${key}'`); } } /** * Split a SCIM PATCH path into top / filter / sub components without relying * on a lookahead-heavy regex (CodeQL flagged the previous pattern for * polynomial backtracking on crafted `$.…` inputs). Each segment is then * validated against a strict linear regex. */ function parsePath(path: string): { top: string; sub?: string; filter?: string; } { if (typeof path !== 'string' || path.length === 0 || path.length > 512) { throw scimInvalidPath(`Malformed path '${String(path)}'`); } let top: string; let filter: string | undefined; let sub: string | undefined; const bracketStart = path.indexOf('['); if (bracketStart >= 0) { const bracketEnd = path.lastIndexOf(']'); if (bracketEnd <= bracketStart) { throw scimInvalidPath(`Malformed path '${path}'`); } top = path.slice(0, bracketStart); filter = path.slice(bracketStart + 1, bracketEnd); const rest = path.slice(bracketEnd + 1); if (rest.length > 0) { if (rest[0] !== '.') { throw scimInvalidPath(`Malformed path '${path}'`); } sub = rest.slice(1); } } else { const dot = path.indexOf('.'); if (dot >= 0) { top = path.slice(0, dot); sub = path.slice(dot + 1); } else { top = path; } } if (!ATTR_NAME_RE.test(top)) { throw scimInvalidPath(`Malformed path '${path}'`); } assertSafeKey(top); if (sub != null) { if (!ATTR_NAME_RE.test(sub)) { throw scimInvalidPath(`Malformed sub-attribute in '${path}'`); } assertSafeKey(sub); } return { top, filter, sub }; } function coerceValue(v: unknown): string | string[] | undefined { if (v == null) return undefined; if (typeof v === 'string') return v; if (typeof v === 'number' || typeof v === 'boolean') return String(v); if (Array.isArray(v)) { return v.map(x => { if (typeof x === 'string') return x; if (typeof x === 'number' || typeof x === 'boolean') return String(x); if ( x && typeof x === 'object' && !Array.isArray(x) && 'value' in x && (typeof (x as { value: unknown }).value === 'string' || typeof (x as { value: unknown }).value === 'number' || typeof (x as { value: unknown }).value === 'boolean') ) { return String((x as { value: unknown }).value); } throw scimInvalidValue( `Unsupported array element in PATCH value: ${JSON.stringify(x)}` ); }); } return undefined; } function mergeAttr( target: AttributesList, attr: string, value: string | string[] ): void { const existing = target[attr]; if (existing == null) { target[attr] = value; return; } const asArr = (v: AttributeValue): string[] => Array.isArray(v) ? v.map(x => (typeof x === 'string' ? x : x.toString())) : [typeof v === 'string' ? v : v.toString()]; const combined = [ ...asArr(existing), ...(Array.isArray(value) ? value : [value]), ]; target[attr] = combined; } /** * Apply a PATCH operation by mutating an in-progress ModifyRequest. * For member-related ops on Groups, caller must supply resolveMemberRef. */ async function applyOperation( op: PatchOperation, req: ModifyRequest, ctx: PatchContext ): Promise { const operation = normalizeOp(op.op); const memberAttr = ctx.memberAttribute || 'member'; // No path: value must be an object with top-level keys if (!op.path) { if ( op.value == null || typeof op.value !== 'object' || Array.isArray(op.value) ) { throw scimNoTarget('PATCH without path requires an object value'); } const valueObj = op.value as Record; for (const [scimAttr, v] of Object.entries(valueObj)) { await applyOperation({ op: op.op, path: scimAttr, value: v }, req, ctx); } return; } const { top, sub, filter } = parsePath(op.path); // Special: members on Groups if (top === 'members') { if (!ctx.resolveMemberRef) { throw scimNoTarget('Member operations require a member resolver'); } if (operation === 'add') { const values = Array.isArray(op.value) ? op.value : [op.value]; const dns: string[] = []; for (const v of values) { const memberValue = typeof v === 'string' ? v : typeof v === 'object' && v != null && 'value' in v ? String((v as { value: unknown }).value) : ''; if (!memberValue) continue; const dn = await ctx.resolveMemberRef(memberValue); if (dn) dns.push(dn); } if (dns.length > 0) { if (!req.add) req.add = {}; mergeAttr(req.add, memberAttr, dns); } return; } if (operation === 'remove') { // Two cases: // - path "members" with no filter but with `value` listing members → remove those // - path 'members[value eq "abc"]' → filter identifies members const collectFromValue = (): string[] => { if (op.value == null) return []; const arr = Array.isArray(op.value) ? op.value : [op.value]; return arr .map(v => typeof v === 'string' ? v : typeof v === 'object' && v != null && 'value' in v ? String((v as { value: unknown }).value) : '' ) .filter(Boolean); }; const collectFromFilter = (): string[] => { if (!filter) return []; const fm = /value\s+eq\s+"([^"]+)"/i.exec(filter); return fm ? [fm[1]] : []; }; const ids = [...collectFromFilter(), ...collectFromValue()]; const dns: string[] = []; for (const id of ids) { const dn = await ctx.resolveMemberRef(id); if (dn) dns.push(dn); } if (dns.length > 0) { if (!req.delete) req.delete = {}; if (Array.isArray(req.delete)) { // Not expected: would have been initialized as array elsewhere. req.delete = { [memberAttr]: dns }; } else { mergeAttr(req.delete, memberAttr, dns); } } else if (!filter && !op.value) { // Remove all members if (!req.delete) req.delete = {}; if (Array.isArray(req.delete)) { req.delete.push(memberAttr); } else { req.delete[memberAttr] = ''; } } return; } if (operation === 'replace') { // replace: remove existing members, add new // ldapts modify with { replace: { member: [...] } } → same semantic const values = Array.isArray(op.value) ? op.value : [op.value]; const dns: string[] = []; for (const v of values) { const memberValue = typeof v === 'string' ? v : typeof v === 'object' && v != null && 'value' in v ? String((v as { value: unknown }).value) : ''; if (!memberValue) continue; const dn = await ctx.resolveMemberRef(memberValue); if (dn) dns.push(dn); } if (dns.length > 0) { if (!req.replace) req.replace = {}; req.replace[memberAttr] = dns; } return; } } // Regular SCIM attributes — reject bracket-filtered paths on anything other // than `members` (already handled above): without real sub-filter semantics // we would silently misapply the operation to the primary value. if (filter) { throw scimInvalidPath( `Complex multi-valued filters are only supported on 'members' (got '${op.path}')` ); } const ldapAttr = sub ? scimPathToLdapAttribute(`${top}.${sub}`, ctx.mapping) : scimPathToLdapAttribute(top, ctx.mapping); if (!ldapAttr) { throw scimInvalidPath(`Unknown SCIM attribute path '${op.path}'`); } if (operation === 'remove') { if (!req.delete) req.delete = {}; if (Array.isArray(req.delete)) { req.delete.push(ldapAttr); } else { req.delete[ldapAttr] = ''; } return; } const value = coerceValue(op.value); if (value == null) { throw scimInvalidValue(`PATCH ${op.op} ${op.path} missing value`); } if (operation === 'add') { if (!req.add) req.add = {}; mergeAttr(req.add, ldapAttr, value); return; } if (operation === 'replace') { if (!req.replace) req.replace = {}; req.replace[ldapAttr] = value; return; } } export async function patchToModifyRequest( patch: PatchRequest, ctx: PatchContext ): Promise { if (!patch.Operations || !Array.isArray(patch.Operations)) { throw scimInvalidValue('Missing Operations array'); } const req: ModifyRequest = {}; for (const op of patch.Operations) { await applyOperation(op, req, ctx); } return req; } /** * Apply PATCH to a SCIM resource object in-memory (used for PUT-equivalent * or when the resource handler prefers object-level manipulation before * falling back to full replace). * * Returns the mutated resource. Used by tests; the main production path * is patchToModifyRequest() → ldap.modify(). */ export function applyPatchToResource>( resource: T, patch: PatchRequest ): T { const out: Record = { ...resource }; for (const op of patch.Operations) { const operation = normalizeOp(op.op); if (!op.path) { if ( op.value && typeof op.value === 'object' && !Array.isArray(op.value) ) { for (const [k, v] of Object.entries(op.value)) { assertSafeKey(k); out[k] = v; } } continue; } // parsePath rejects forbidden keys (__proto__, constructor, prototype) // so the bracket / dot assignments below are safe from prototype pollution. const { top, sub } = parsePath(op.path); if (operation === 'remove') { if (sub) { const obj = out[top]; if (obj && typeof obj === 'object') { delete (obj as Record)[sub]; } } else { delete out[top]; } continue; } if (sub) { const existing = out[top]; const obj: Record = existing && typeof existing === 'object' && !Array.isArray(existing) ? { ...(existing as Record) } : (Object.create(null) as Record); obj[sub] = op.value; out[top] = obj; } else { out[top] = op.value; } } return out as T; } linagora-ldap-rest-16e557e/src/plugins/scim/scim.ts000066400000000000000000001676031522642357000222730ustar00rootroot00000000000000/** * @module plugins/scim/scim * @author Xavier Guimard * * SCIM 2.0 plugin. Loads via `--plugin core/scim`. * * Exposes /scim/v2/{Users,Groups,Bulk,ServiceProviderConfig,ResourceTypes,Schemas}. */ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import type { Express, Request } from 'express'; import bodyParser from 'body-parser'; import DmPlugin, { type Role } from '../../abstract/plugin'; import type { DM } from '../../bin'; import type ldapActions from '../../lib/ldapActions'; import type { DmRequest } from '../../lib/auth/base'; import { BaseResolver } from './baseResolver'; import { ScimUsers } from './users'; import { ScimGroups } from './groups'; import { ScimBulk } from './bulk'; import { ScimDiscovery } from './discovery'; import { scimAsyncHandler, writeScimError, writeScimErrorFromException, SCIM_CONTENT_TYPE, } from './errors'; import { type BulkRequest, type PatchRequest, type ScimGroup, type ScimUser, } from './types'; /** * Reusable SCIM 2.0 schemas surfaced by this plugin. * Picked up by scripts/generate-openapi.ts and merged into `components.schemas`. * * @openapi-component * ScimUser: * type: object * description: | * SCIM 2.0 User resource (RFC 7643 §4.1). * required: [schemas, userName] * properties: * schemas: * type: array * items: { type: string } * example: ['urn:ietf:params:scim:schemas:core:2.0:User'] * id: * type: string * description: Opaque server-assigned identifier. * example: 2819c223-7f76-453a-919d-413861904646 * externalId: * type: string * description: Provisioner-assigned identifier. * example: 701984 * userName: * type: string * description: Unique user identifier (login name). * example: bjensen * name: * type: object * properties: * formatted: { type: string, example: Ms. Barbara J Jensen III } * familyName: { type: string, example: Jensen } * givenName: { type: string, example: Barbara } * middleName: { type: string, example: Jane } * honorificPrefix: { type: string, example: Ms. } * honorificSuffix: { type: string, example: III } * displayName: * type: string * example: Babs Jensen * nickName: * type: string * example: Babs * profileUrl: * type: string * example: https://login.example.com/bjensen * title: * type: string * example: Tour Guide * userType: * type: string * example: Employee * preferredLanguage: * type: string * example: en-US * locale: * type: string * example: en-US * timezone: * type: string * example: America/Los_Angeles * active: * type: boolean * example: true * emails: * type: array * items: * type: object * properties: * value: { type: string } * display: { type: string } * type: { type: string } * primary: { type: boolean } * example: * - value: bjensen@example.com * type: work * primary: true * phoneNumbers: * type: array * items: * type: object * properties: * value: { type: string } * type: { type: string } * primary: { type: boolean } * addresses: * type: array * items: * type: object * properties: * streetAddress: { type: string } * locality: { type: string } * region: { type: string } * postalCode: { type: string } * country: { type: string } * type: { type: string } * primary: { type: boolean } * groups: * type: array * description: Groups the user belongs to (read-only). * items: * type: object * properties: * value: { type: string } * $ref: { type: string } * display: { type: string } * meta: * type: object * properties: * resourceType: { type: string, example: User } * created: { type: string, format: date-time } * lastModified: { type: string, format: date-time } * location: { type: string } * version: { type: string } * ScimGroup: * type: object * description: | * SCIM 2.0 Group resource (RFC 7643 §4.2). * required: [schemas, displayName] * properties: * schemas: * type: array * items: { type: string } * example: ['urn:ietf:params:scim:schemas:core:2.0:Group'] * id: * type: string * description: Opaque server-assigned identifier. * example: e9e30dba-f08f-4109-8486-d5c6a331660a * externalId: * type: string * example: 00g1emaKYZTWRINFRGETl * displayName: * type: string * example: Tour Guides * members: * type: array * description: Members of the group. * items: * type: object * properties: * value: { type: string, description: User or Group id } * $ref: { type: string } * display: { type: string } * example: * - value: 2819c223-7f76-453a-919d-413861904646 * display: Babs Jensen * - value: 902c246b-6245-4190-8e05-00816be7344a * display: Mandy Pepperidge * meta: * type: object * properties: * resourceType: { type: string, example: Group } * created: { type: string, format: date-time } * lastModified: { type: string, format: date-time } * location: { type: string } * version: { type: string } * ScimListResponse: * type: object * description: | * SCIM 2.0 list query response (urn:ietf:params:scim:api:messages:2.0:ListResponse). * required: [schemas, totalResults, Resources] * properties: * schemas: * type: array * items: { type: string } * example: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'] * totalResults: * type: integer * description: Total number of matching resources. * example: 2 * startIndex: * type: integer * description: 1-based index of the first result returned. * example: 1 * itemsPerPage: * type: integer * description: Number of resources returned in this page. * example: 2 * Resources: * type: array * items: {} * description: The matching resources. * ScimError: * type: object * description: | * SCIM 2.0 error response (urn:ietf:params:scim:api:messages:2.0:Error). * required: [schemas, status] * properties: * schemas: * type: array * items: { type: string } * example: ['urn:ietf:params:scim:api:messages:2.0:Error'] * status: * type: string * description: HTTP status code as a string. * example: '404' * scimType: * type: string * description: | * SCIM error type keyword (RFC 7644 §3.12). One of: invalidFilter, * tooMany, uniqueness, mutability, invalidSyntax, invalidPath, * noTarget, invalidValue, invalidVers, sensitive. * example: mutability * detail: * type: string * description: Human-readable error description. * example: Resource 2819c223-7f76-453a-919d-413861904646 not found. * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'] * status: '404' * detail: Resource 2819c223-7f76-453a-919d-413861904646 not found. * ScimPatchOp: * type: object * description: | * SCIM 2.0 patch request body (urn:ietf:params:scim:api:messages:2.0:PatchOp, * RFC 7644 §3.5.2). * required: [schemas, Operations] * properties: * schemas: * type: array * items: { type: string } * example: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'] * Operations: * type: array * items: * type: object * required: [op] * properties: * op: * type: string * enum: [add, remove, replace, Add, Remove, Replace] * path: * type: string * description: SCIM attribute path (e.g. "emails[type eq \"work\"].value"). * value: * description: New value for the target attribute. * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'] * Operations: * - op: replace * path: active * value: false * - op: replace * path: emails[type eq "work"].value * value: bjensen@newdomain.example.com * ScimBulkRequest: * type: object * description: | * SCIM 2.0 Bulk request (urn:ietf:params:scim:api:messages:2.0:BulkRequest, * RFC 7644 §3.7). * required: [schemas, Operations] * properties: * schemas: * type: array * items: { type: string } * example: ['urn:ietf:params:scim:api:messages:2.0:BulkRequest'] * failOnErrors: * type: integer * description: | * Stop processing after this many errors. Omit or set to 0 to * continue on errors and process all operations. * example: 1 * Operations: * type: array * items: * type: object * required: [method, path] * properties: * method: * type: string * enum: [POST, PUT, PATCH, DELETE] * bulkId: * type: string * description: | * Temporary client-assigned identifier for this operation. * Subsequent operations in the same request may reference the * created resource as "bulkId:". * path: * type: string * description: Resource path relative to the SCIM prefix (e.g. /Users or /Groups/123). * data: * description: Request body for POST/PUT/PATCH operations. * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:BulkRequest'] * failOnErrors: 1 * Operations: * - method: POST * bulkId: qwerty * path: /Users * data: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'] * userName: bjensen * name: * familyName: Jensen * givenName: Barbara * - method: DELETE * path: /Users/2819c223-7f76-453a-919d-413861904646 * ScimBulkResponse: * type: object * description: | * SCIM 2.0 Bulk response (urn:ietf:params:scim:api:messages:2.0:BulkResponse). * required: [schemas, Operations] * properties: * schemas: * type: array * items: { type: string } * example: ['urn:ietf:params:scim:api:messages:2.0:BulkResponse'] * Operations: * type: array * items: * type: object * required: [method, status] * properties: * method: * type: string * enum: [POST, PUT, PATCH, DELETE] * bulkId: * type: string * location: * type: string * description: URL of the created/modified resource. * version: * type: string * status: * type: string * description: HTTP status code for this individual operation. * response: * $ref: '#/components/schemas/ScimError' * description: Present only when the operation failed. * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:BulkResponse'] * Operations: * - method: POST * bulkId: qwerty * location: https://example.com/scim/v2/Users/2819c223-7f76-453a-919d-413861904646 * status: '201' * - method: DELETE * status: '204' * ScimResourceType: * type: object * description: SCIM 2.0 ResourceType definition (RFC 7643 §6). * required: [schemas, id, name, endpoint, schema] * properties: * schemas: * type: array * items: { type: string } * example: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'] * id: { type: string, example: User } * name: { type: string, example: User } * endpoint: { type: string, example: /Users } * description: { type: string, example: User Account } * schema: * type: string * example: urn:ietf:params:scim:schemas:core:2.0:User * schemaExtensions: * type: array * items: * type: object * properties: * schema: { type: string } * required: { type: boolean } * meta: * type: object * properties: * resourceType: { type: string, example: ResourceType } * location: { type: string } * ScimSchema: * type: object * description: SCIM 2.0 Schema definition (RFC 7643 §7). * required: [schemas, id, name, attributes] * properties: * schemas: * type: array * items: { type: string } * example: ['urn:ietf:params:scim:schemas:core:2.0:Schema'] * id: * type: string * example: urn:ietf:params:scim:schemas:core:2.0:User * name: { type: string, example: User } * description: { type: string, example: User Account } * attributes: * type: array * items: {} * description: Schema attribute definitions. * meta: * type: object * properties: * resourceType: { type: string, example: Schema } * location: { type: string } */ export default class Scim extends DmPlugin { name = 'scim'; roles: Role[] = ['api', 'configurable'] as const; ldap: ldapActions; private readonly scimPrefix: string; private readonly users: ScimUsers; private readonly groups: ScimGroups; private readonly bulk: ScimBulk; private readonly discovery: ScimDiscovery; private readonly baseResolver: BaseResolver; constructor(server: DM) { super(server); this.ldap = server.ldap; this.scimPrefix = (this.config.scim_prefix as string) || '/scim/v2'; this.baseResolver = new BaseResolver(this.config); this.users = new ScimUsers({ ldap: this.ldap, config: this.config, logger: this.logger, baseResolver: this.baseResolver, hooks: this.registeredHooks as { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type [K: string]: Function[] | undefined; }, }); this.groups = new ScimGroups({ ldap: this.ldap, config: this.config, logger: this.logger, baseResolver: this.baseResolver, users: this.users, hooks: this.registeredHooks as { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type [K: string]: Function[] | undefined; }, }); this.bulk = new ScimBulk({ config: this.config, logger: this.logger, users: this.users, groups: this.groups, scimPrefix: this.scimPrefix, hooks: this.registeredHooks as { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type [K: string]: Function[] | undefined; }, }); const schemaDir = this.config.schemas_path || join( dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'static', 'schemas' ); this.discovery = new ScimDiscovery({ config: this.config, schemaDir: join(schemaDir, 'scim'), scimPrefix: this.scimPrefix, loadedPlugins: server.loadedPlugins, }); } private scimJson( res: import('express').Response, status: number, body: unknown ): void { res.status(status).type(SCIM_CONTENT_TYPE).json(body); } private readScimBody(req: Request): unknown { // Accept application/scim+json as well as application/json const ct = String(req.headers['content-type'] || ''); if ( !ct.startsWith('application/scim+json') && !ct.startsWith('application/json') ) { return undefined; } return req.body; } api(app: Express): void { const prefix = this.scimPrefix; // SCIM 2.0 uses Content-Type: application/scim+json (RFC 7644 §3.1.1). // The global bodyParser.json() installed by DM only accepts application/json, // so we install a scoped parser that handles both for all SCIM routes. app.use( prefix, bodyParser.json({ type: ['application/json', 'application/scim+json'], limit: `${ (this.config.scim_bulk_max_payload_size as number) || 1048576 }b`, }) ); /** Users */ /** * @openapi * summary: List users * description: | * Returns a paginated list of SCIM User resources matching the optional * filter. Pagination is controlled by `startIndex` (1-based) and `count`. * The response always uses the `ListResponse` envelope. * parameters: * - in: query * name: filter * schema: { type: string } * description: | * SCIM filter expression (RFC 7644 §3.4.2.2), e.g. * `userName eq "bjensen"` or `emails.value co "@example.com"`. * example: 'userName eq "bjensen"' * - in: query * name: startIndex * schema: { type: integer, default: 1 } * description: 1-based index of the first result to return. * example: 1 * - in: query * name: count * schema: { type: integer } * description: Maximum number of results to return per page. * example: 10 * - in: query * name: attributes * schema: { type: string } * description: Comma-separated SCIM attribute names to include in the response. * example: 'userName,emails' * - in: query * name: excludedAttributes * schema: { type: string } * description: Comma-separated SCIM attribute names to exclude from the response. * example: 'password,x509Certificates' * - in: query * name: sortBy * schema: { type: string } * description: Attribute to sort results by (parsed but sort not guaranteed server-side). * - in: query * name: sortOrder * schema: { type: string, enum: [ascending, descending] } * description: Sort direction. * responses: * '200': * description: User list. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimListResponse' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'] * totalResults: 2 * startIndex: 1 * itemsPerPage: 2 * Resources: * - schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'] * id: 2819c223-7f76-453a-919d-413861904646 * userName: bjensen * displayName: Babs Jensen * active: true * emails: * - value: bjensen@example.com * type: work * primary: true * - schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'] * id: c75ad752-64ae-4823-840d-ffa80929976c * userName: jsmith * displayName: John Smith * active: true * '400': * description: Invalid filter expression. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'] * status: '400' * scimType: invalidFilter * detail: 'Filter parse error: unexpected token near "eq"' */ app.get( `${prefix}/Users`, scimAsyncHandler(async (req, res) => { const q = this.parseListQuery(req); const list = await this.users.list(req as DmRequest, q); this.scimJson(res, 200, list); }) ); /** * @openapi * summary: Get user by ID * description: | * Returns a single SCIM User resource. The `:id` value is the server- * assigned opaque identifier returned when the resource was created. * responses: * '200': * description: User resource. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimUser' } * example: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'] * id: 2819c223-7f76-453a-919d-413861904646 * externalId: '701984' * userName: bjensen * name: * formatted: Ms. Barbara J Jensen III * familyName: Jensen * givenName: Barbara * honorificPrefix: Ms. * honorificSuffix: III * displayName: Babs Jensen * active: true * emails: * - value: bjensen@example.com * type: work * primary: true * meta: * resourceType: User * created: '2024-01-10T09:00:00Z' * lastModified: '2024-01-15T12:30:00Z' * location: 'https://example.com/scim/v2/Users/2819c223-7f76-453a-919d-413861904646' * '404': * description: User not found. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'] * status: '404' * detail: Resource 2819c223-7f76-453a-919d-413861904646 not found. */ app.get( `${prefix}/Users/:id`, scimAsyncHandler(async (req, res) => { const user = await this.users.get( req as DmRequest, decodeURIComponent(req.params.id as string) ); this.scimJson(res, 200, user); }) ); /** * @openapi * summary: Create user * description: | * Creates a new SCIM User resource. The `userName` attribute is required. * Returns the created resource with status 201 and a `Location` header. * requestBody: * required: true * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimUser' } * example: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'] * userName: bjensen * externalId: '701984' * name: * familyName: Jensen * givenName: Barbara * displayName: Babs Jensen * emails: * - value: bjensen@example.com * type: work * primary: true * active: true * responses: * '201': * description: User created. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimUser' } * example: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'] * id: 2819c223-7f76-453a-919d-413861904646 * userName: bjensen * displayName: Babs Jensen * active: true * meta: * resourceType: User * created: '2024-01-10T09:00:00Z' * location: 'https://example.com/scim/v2/Users/2819c223-7f76-453a-919d-413861904646' * '400': * description: Request body is missing or malformed. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'] * status: '400' * scimType: invalidSyntax * detail: 'Missing body' * '409': * description: A user with that userName already exists. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'] * status: '409' * scimType: uniqueness * detail: 'User bjensen already exists' */ app.post( `${prefix}/Users`, scimAsyncHandler(async (req, res) => { const body = this.readScimBody(req); if (!body || typeof body !== 'object') { return writeScimError(res, 400, 'Missing body', 'invalidSyntax'); } const user = await this.users.create( req as DmRequest, body as ScimUser ); this.scimJson(res, 201, user); }) ); /** * @openapi * summary: Replace user * description: | * Replaces all attributes of an existing SCIM User resource with the * values in the request body (full replacement, RFC 7644 §3.5.1). * Any attributes omitted from the body are cleared on the server. * requestBody: * required: true * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimUser' } * example: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'] * id: 2819c223-7f76-453a-919d-413861904646 * userName: bjensen * name: * familyName: Jensen * givenName: Barbara * displayName: Babs Jensen * emails: * - value: bjensen@newdomain.example.com * type: work * primary: true * active: true * responses: * '200': * description: User replaced. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimUser' } * example: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'] * id: 2819c223-7f76-453a-919d-413861904646 * userName: bjensen * displayName: Babs Jensen * active: true * '400': * description: Request body is missing or malformed. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * '404': * description: User not found. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'] * status: '404' * detail: Resource 2819c223-7f76-453a-919d-413861904646 not found. */ app.put( `${prefix}/Users/:id`, scimAsyncHandler(async (req, res) => { const body = this.readScimBody(req); if (!body || typeof body !== 'object') { return writeScimError(res, 400, 'Missing body', 'invalidSyntax'); } const user = await this.users.replace( req as DmRequest, decodeURIComponent(req.params.id as string), body as ScimUser ); this.scimJson(res, 200, user); }) ); /** * @openapi * summary: Patch user * description: | * Applies a partial update to a SCIM User resource using PatchOp * (RFC 7644 §3.5.2). Supported operations: `add`, `remove`, `replace`. * The `path` attribute uses SCIM attribute notation including filter * expressions (e.g. `emails[type eq "work"].value`). * requestBody: * required: true * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimPatchOp' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'] * Operations: * - op: replace * path: active * value: false * - op: replace * path: 'emails[type eq "work"].value' * value: bjensen@newdomain.example.com * responses: * '200': * description: User patched. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimUser' } * example: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'] * id: 2819c223-7f76-453a-919d-413861904646 * userName: bjensen * active: false * '400': * description: Request body is missing or malformed. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * '404': * description: User not found. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } */ app.patch( `${prefix}/Users/:id`, scimAsyncHandler(async (req, res) => { const body = this.readScimBody(req); if (!body || typeof body !== 'object') { return writeScimError(res, 400, 'Missing body', 'invalidSyntax'); } const user = await this.users.patch( req as DmRequest, decodeURIComponent(req.params.id as string), body as PatchRequest ); this.scimJson(res, 200, user); }) ); /** * @openapi * summary: Delete user * description: | * Permanently removes a SCIM User resource. Returns 204 No Content on * success; the response body is empty. * responses: * '204': * description: User deleted. * '404': * description: User not found. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'] * status: '404' * detail: Resource 2819c223-7f76-453a-919d-413861904646 not found. */ app.delete( `${prefix}/Users/:id`, scimAsyncHandler(async (req, res) => { await this.users.delete( req as DmRequest, decodeURIComponent(req.params.id as string) ); res.status(204).end(); }) ); /** Groups */ /** * @openapi * summary: List groups * description: | * Returns a paginated list of SCIM Group resources matching the optional * filter. Pagination is controlled by `startIndex` (1-based) and `count`. * parameters: * - in: query * name: filter * schema: { type: string } * description: | * SCIM filter expression (RFC 7644 §3.4.2.2), e.g. * `displayName eq "Tour Guides"`. * example: 'displayName eq "Tour Guides"' * - in: query * name: startIndex * schema: { type: integer, default: 1 } * description: 1-based index of the first result to return. * example: 1 * - in: query * name: count * schema: { type: integer } * description: Maximum number of results to return per page. * example: 10 * - in: query * name: attributes * schema: { type: string } * description: Comma-separated SCIM attribute names to include. * example: 'displayName,members' * - in: query * name: excludedAttributes * schema: { type: string } * description: Comma-separated SCIM attribute names to exclude. * - in: query * name: sortBy * schema: { type: string } * description: Attribute to sort by (parsed but not guaranteed server-side). * - in: query * name: sortOrder * schema: { type: string, enum: [ascending, descending] } * responses: * '200': * description: Group list. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimListResponse' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'] * totalResults: 2 * startIndex: 1 * itemsPerPage: 2 * Resources: * - schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'] * id: e9e30dba-f08f-4109-8486-d5c6a331660a * displayName: Tour Guides * members: * - value: 2819c223-7f76-453a-919d-413861904646 * display: Babs Jensen * - schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'] * id: fc348aa8-3835-40eb-a20b-c726e15c55b5 * displayName: Employees * members: [] * '400': * description: Invalid filter expression. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } */ app.get( `${prefix}/Groups`, scimAsyncHandler(async (req, res) => { const q = this.parseListQuery(req); const list = await this.groups.list(req as DmRequest, q); this.scimJson(res, 200, list); }) ); /** * @openapi * summary: Get group by ID * description: Returns a single SCIM Group resource by its server-assigned ID. * responses: * '200': * description: Group resource. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimGroup' } * example: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'] * id: e9e30dba-f08f-4109-8486-d5c6a331660a * displayName: Tour Guides * members: * - value: 2819c223-7f76-453a-919d-413861904646 * display: Babs Jensen * - value: 902c246b-6245-4190-8e05-00816be7344a * display: Mandy Pepperidge * meta: * resourceType: Group * created: '2024-01-10T09:00:00Z' * lastModified: '2024-01-15T12:30:00Z' * location: 'https://example.com/scim/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a' * '404': * description: Group not found. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'] * status: '404' * detail: Resource e9e30dba-f08f-4109-8486-d5c6a331660a not found. */ app.get( `${prefix}/Groups/:id`, scimAsyncHandler(async (req, res) => { const group = await this.groups.get( req as DmRequest, decodeURIComponent(req.params.id as string) ); this.scimJson(res, 200, group); }) ); /** * @openapi * summary: Create group * description: | * Creates a new SCIM Group resource. The `displayName` attribute is * required. Returns the created resource with status 201 and a * `Location` header. * requestBody: * required: true * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimGroup' } * example: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'] * displayName: Tour Guides * members: * - value: 2819c223-7f76-453a-919d-413861904646 * display: Babs Jensen * responses: * '201': * description: Group created. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimGroup' } * example: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'] * id: e9e30dba-f08f-4109-8486-d5c6a331660a * displayName: Tour Guides * members: * - value: 2819c223-7f76-453a-919d-413861904646 * display: Babs Jensen * meta: * resourceType: Group * created: '2024-01-10T09:00:00Z' * location: 'https://example.com/scim/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a' * '400': * description: Request body is missing or malformed. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * '409': * description: A group with that displayName already exists. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'] * status: '409' * scimType: uniqueness * detail: 'Group Tour Guides already exists' */ app.post( `${prefix}/Groups`, scimAsyncHandler(async (req, res) => { const body = this.readScimBody(req); if (!body || typeof body !== 'object') { return writeScimError(res, 400, 'Missing body', 'invalidSyntax'); } const group = await this.groups.create( req as DmRequest, body as ScimGroup ); this.scimJson(res, 201, group); }) ); /** * @openapi * summary: Replace group * description: | * Replaces all attributes of an existing SCIM Group resource with the * values in the request body (full replacement, RFC 7644 §3.5.1). * requestBody: * required: true * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimGroup' } * example: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'] * id: e9e30dba-f08f-4109-8486-d5c6a331660a * displayName: Tour Guides * members: * - value: 2819c223-7f76-453a-919d-413861904646 * display: Babs Jensen * - value: 902c246b-6245-4190-8e05-00816be7344a * display: Mandy Pepperidge * responses: * '200': * description: Group replaced. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimGroup' } * example: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'] * id: e9e30dba-f08f-4109-8486-d5c6a331660a * displayName: Tour Guides * members: * - value: 2819c223-7f76-453a-919d-413861904646 * display: Babs Jensen * '400': * description: Request body is missing or malformed. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * '404': * description: Group not found. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } */ app.put( `${prefix}/Groups/:id`, scimAsyncHandler(async (req, res) => { const body = this.readScimBody(req); if (!body || typeof body !== 'object') { return writeScimError(res, 400, 'Missing body', 'invalidSyntax'); } const group = await this.groups.replace( req as DmRequest, decodeURIComponent(req.params.id as string), body as ScimGroup ); this.scimJson(res, 200, group); }) ); /** * @openapi * summary: Patch group * description: | * Applies a partial update to a SCIM Group resource using PatchOp * (RFC 7644 §3.5.2). Typical use-cases: adding or removing members, * renaming the group. * requestBody: * required: true * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimPatchOp' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'] * Operations: * - op: add * path: members * value: * - value: 2819c223-7f76-453a-919d-413861904646 * display: Babs Jensen * - op: remove * path: 'members[value eq "902c246b-6245-4190-8e05-00816be7344a"]' * responses: * '200': * description: Group patched. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimGroup' } * example: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'] * id: e9e30dba-f08f-4109-8486-d5c6a331660a * displayName: Tour Guides * members: * - value: 2819c223-7f76-453a-919d-413861904646 * display: Babs Jensen * '400': * description: Request body is missing or malformed. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * '404': * description: Group not found. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } */ app.patch( `${prefix}/Groups/:id`, scimAsyncHandler(async (req, res) => { const body = this.readScimBody(req); if (!body || typeof body !== 'object') { return writeScimError(res, 400, 'Missing body', 'invalidSyntax'); } const group = await this.groups.patch( req as DmRequest, decodeURIComponent(req.params.id as string), body as PatchRequest ); this.scimJson(res, 200, group); }) ); /** * @openapi * summary: Delete group * description: | * Permanently removes a SCIM Group resource. Returns 204 No Content on * success; the response body is empty. * responses: * '204': * description: Group deleted. * '404': * description: Group not found. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'] * status: '404' * detail: Resource e9e30dba-f08f-4109-8486-d5c6a331660a not found. */ app.delete( `${prefix}/Groups/:id`, scimAsyncHandler(async (req, res) => { await this.groups.delete( req as DmRequest, decodeURIComponent(req.params.id as string) ); res.status(204).end(); }) ); /** Bulk */ /** * @openapi * summary: Execute bulk operations * description: | * Executes multiple SCIM operations in a single HTTP request (RFC 7644 * §3.7). Operations are processed sequentially. Supports `bulkId` * cross-references: a POST operation with `"bulkId":"abc"` creates a * resource; subsequent operations in the same request can reference it * as `"bulkId:abc"` wherever a SCIM id is expected. * * The `failOnErrors` field controls how many errors to tolerate before * aborting. Set to 0 (default) to process all operations regardless of * individual errors. * * Maximum payload size and operation count are configurable server-side * (defaults: 1 MiB / 100 operations). Exceeding either returns 413. * requestBody: * required: true * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimBulkRequest' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:BulkRequest'] * failOnErrors: 1 * Operations: * - method: POST * bulkId: qwerty * path: /Users * data: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'] * userName: bjensen * name: * familyName: Jensen * givenName: Barbara * - method: DELETE * path: /Users/2819c223-7f76-453a-919d-413861904646 * responses: * '200': * description: Bulk operations completed (may include per-operation errors). * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimBulkResponse' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:BulkResponse'] * Operations: * - method: POST * bulkId: qwerty * location: 'https://example.com/scim/v2/Users/2819c223-7f76-453a-919d-413861904646' * status: '201' * - method: DELETE * status: '204' * '400': * description: Request body is missing, malformed, or exceeds operation count limit. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * '413': * description: Payload exceeds maximum allowed size. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'] * status: '413' * scimType: invalidValue * detail: 'Bulk payload too large: 2097152 > 1048576' */ app.post( `${prefix}/Bulk`, scimAsyncHandler(async (req, res) => { const body = this.readScimBody(req); if (!body || typeof body !== 'object') { return writeScimError(res, 400, 'Missing body', 'invalidSyntax'); } const maxPayload = (this.config.scim_bulk_max_payload_size as number) || 1048576; const size = JSON.stringify(body).length; if (size > maxPayload) { return writeScimError( res, 413, `Bulk payload too large: ${size} > ${maxPayload}`, 'invalidValue' ); } const response = await this.bulk.execute( req as DmRequest, body as BulkRequest ); this.scimJson(res, 200, response); }) ); /** Discovery */ /** * @openapi * summary: Get service provider configuration * description: | * Returns the SCIM 2.0 ServiceProviderConfig document (RFC 7643 §5). * Describes which optional features this server supports: PATCH, Bulk, * filtering, sorting, ETags, and the active authentication schemes. * No authentication is required for this endpoint. * responses: * '200': * description: Service provider configuration. * content: * application/scim+json: * example: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'] * patch: { supported: true } * bulk: * supported: true * maxOperations: 100 * maxPayloadSize: 1048576 * filter: { supported: true, maxResults: 200 } * changePassword: { supported: false } * sort: { supported: false } * etag: { supported: false } * authenticationSchemes: * - name: OAuth Bearer Token * description: 'Bearer token. Send "Authorization: Bearer ".' * type: oauthbearertoken * primary: true * meta: * resourceType: ServiceProviderConfig * location: 'https://example.com/scim/v2/ServiceProviderConfig' */ app.get( `${prefix}/ServiceProviderConfig`, scimAsyncHandler((req, res) => { this.scimJson( res, 200, this.discovery.serviceProviderConfig(req as DmRequest) ); }) ); /** * @openapi * summary: List resource types * description: | * Returns all registered SCIM ResourceType definitions wrapped in a * ListResponse envelope (RFC 7643 §6). This server exposes `User` and * `Group` resource types. * responses: * '200': * description: Resource types list. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimListResponse' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'] * totalResults: 2 * startIndex: 1 * itemsPerPage: 2 * Resources: * - schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'] * id: User * name: User * endpoint: /Users * description: User Account * schema: urn:ietf:params:scim:schemas:core:2.0:User * meta: * resourceType: ResourceType * location: 'https://example.com/scim/v2/ResourceTypes/User' * - schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'] * id: Group * name: Group * endpoint: /Groups * description: Group * schema: urn:ietf:params:scim:schemas:core:2.0:Group * meta: * resourceType: ResourceType * location: 'https://example.com/scim/v2/ResourceTypes/Group' */ app.get( `${prefix}/ResourceTypes`, scimAsyncHandler((req, res) => { const types = this.discovery.resourceTypes(req as DmRequest); this.scimJson(res, 200, { schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'], totalResults: types.length, Resources: types, startIndex: 1, itemsPerPage: types.length, }); }) ); /** * @openapi * summary: Get resource type by name * description: | * Returns a single SCIM ResourceType definition by its `id` (name). * Supported values: `User`, `Group`. * responses: * '200': * description: Resource type definition. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimResourceType' } * example: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'] * id: User * name: User * endpoint: /Users * description: User Account * schema: urn:ietf:params:scim:schemas:core:2.0:User * meta: * resourceType: ResourceType * location: 'https://example.com/scim/v2/ResourceTypes/User' * '404': * description: ResourceType not found. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'] * status: '404' * detail: ResourceType not found */ app.get( `${prefix}/ResourceTypes/:name`, scimAsyncHandler((req, res) => { const rt = this.discovery.resourceType( req.params.name as string, req as DmRequest ); if (!rt) return writeScimError(res, 404, 'ResourceType not found'); this.scimJson(res, 200, rt); }) ); /** * @openapi * summary: List schemas * description: | * Returns all SCIM Schema definitions wrapped in a ListResponse envelope * (RFC 7643 §7). Schemas describe the attributes available on User and * Group resources. Schema files are loaded from the server's configured * `schemas_path` (defaults to `static/schemas/scim/`). * responses: * '200': * description: Schema list. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimListResponse' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'] * totalResults: 2 * startIndex: 1 * itemsPerPage: 2 * Resources: * - schemas: ['urn:ietf:params:scim:schemas:core:2.0:Schema'] * id: urn:ietf:params:scim:schemas:core:2.0:User * name: User * description: User Account * attributes: [] * meta: * resourceType: Schema * location: 'https://example.com/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User' * - schemas: ['urn:ietf:params:scim:schemas:core:2.0:Schema'] * id: urn:ietf:params:scim:schemas:core:2.0:Group * name: Group * description: Group * attributes: [] * meta: * resourceType: Schema * location: 'https://example.com/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:Group' */ app.get( `${prefix}/Schemas`, scimAsyncHandler((req, res) => { const schemas = this.discovery.schemas(req as DmRequest); this.scimJson(res, 200, { schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'], totalResults: schemas.length, Resources: schemas, startIndex: 1, itemsPerPage: schemas.length, }); }) ); /** * @openapi * summary: Get schema by ID * description: | * Returns a single SCIM Schema definition by its URN identifier. * The `:id` segment must be URL-encoded (the generator extracts it as * a path parameter automatically). Example URNs: * - `urn:ietf:params:scim:schemas:core:2.0:User` * - `urn:ietf:params:scim:schemas:core:2.0:Group` * responses: * '200': * description: Schema definition. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimSchema' } * example: * schemas: ['urn:ietf:params:scim:schemas:core:2.0:Schema'] * id: urn:ietf:params:scim:schemas:core:2.0:User * name: User * description: User Account * attributes: * - name: userName * type: string * multiValued: false * required: true * caseExact: false * mutability: readWrite * returned: default * uniqueness: server * meta: * resourceType: Schema * location: 'https://example.com/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User' * '404': * description: Schema not found. * content: * application/scim+json: * schema: { $ref: '#/components/schemas/ScimError' } * example: * schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'] * status: '404' * detail: Schema not found */ app.get( `${prefix}/Schemas/:id`, scimAsyncHandler((req, res) => { const s = this.discovery.schema( decodeURIComponent(req.params.id as string), req as DmRequest ); if (!s) return writeScimError(res, 404, 'Schema not found'); this.scimJson(res, 200, s); }) ); } private parseListQuery(req: Request): { filter?: string; startIndex?: number; count?: number; attributes?: string[]; excludedAttributes?: string[]; sortBy?: string; sortOrder?: 'ascending' | 'descending'; } { const q = req.query; const toInt = (v: unknown): number | undefined => { if (typeof v !== 'string') return undefined; const n = parseInt(v, 10); return Number.isFinite(n) ? n : undefined; }; const toCsv = (v: unknown): string[] | undefined => { if (typeof v !== 'string') return undefined; return v .split(',') .map(s => s.trim()) .filter(Boolean); }; const sortOrder = typeof q.sortOrder === 'string' ? q.sortOrder : undefined; return { filter: typeof q.filter === 'string' ? q.filter : undefined, startIndex: toInt(q.startIndex), count: toInt(q.count), attributes: toCsv(q.attributes), excludedAttributes: toCsv(q.excludedAttributes), sortBy: typeof q.sortBy === 'string' ? q.sortBy : undefined, sortOrder: sortOrder === 'ascending' || sortOrder === 'descending' ? sortOrder : undefined, }; } getConfigApiData(): Record { const prefix = this.scimPrefix; // Expose SCIM core schema files via the static plugin when loaded, so // clients can fetch them without going through the SCIM /Schemas endpoint. let schemaUrls: Record | undefined; if (this.server.loadedPlugins['static']) { const staticName = (this.config.static_name as string) || 'static'; schemaUrls = { user: `/${staticName}/schemas/scim/User.json`, group: `/${staticName}/schemas/scim/Group.json`, defaultMapping: `/${staticName}/schemas/scim/default-mapping.json`, }; } // Report the active LDAP↔SCIM mapping so UI builders can render // attribute selectors / filter helpers without re-parsing the server config. const userMapping = this.users.userMapping; const groupMapping = this.groups.groupMapping; const filterableUserAttrs = userMapping.entries .map(e => e.scim) .concat(['id', 'active']); const filterableGroupAttrs = groupMapping.entries .map(e => e.scim) .concat(['id']); return { enabled: true, version: '2.0', prefix, endpoints: { users: `${prefix}/Users`, user: `${prefix}/Users/:id`, groups: `${prefix}/Groups`, group: `${prefix}/Groups/:id`, bulk: `${prefix}/Bulk`, serviceProviderConfig: `${prefix}/ServiceProviderConfig`, resourceTypes: `${prefix}/ResourceTypes`, schemas: `${prefix}/Schemas`, }, schemaUrls, capabilities: { patch: true, bulk: true, filter: true, sort: true, etag: Boolean(this.config.scim_etag), changePassword: false, maxResults: (this.config.scim_max_results as number) || 200, bulkMaxOperations: (this.config.scim_bulk_max_operations as number) || 100, bulkMaxPayloadSize: (this.config.scim_bulk_max_payload_size as number) || 1048576, }, resourceTypes: [ { id: 'User', endpoint: `${prefix}/Users`, schema: 'urn:ietf:params:scim:schemas:core:2.0:User', rdnAttribute: (this.config.scim_user_rdn_attribute as string) || 'uid', objectClass: this.config.scim_user_object_class as string[], filterableAttributes: filterableUserAttrs, mapping: userMapping.entries, }, { id: 'Group', endpoint: `${prefix}/Groups`, schema: 'urn:ietf:params:scim:schemas:core:2.0:Group', rdnAttribute: (this.config.scim_group_rdn_attribute as string) || 'cn', objectClass: this.config.scim_group_object_class as string[], filterableAttributes: filterableGroupAttrs, mapping: groupMapping.entries, }, ], idStrategy: (this.config.scim_id_attribute as string) || 'rdn', baseResolution: { userBaseTemplate: (this.config.scim_user_base_template as string) || undefined, groupBaseTemplate: (this.config.scim_group_base_template as string) || undefined, hasBaseMap: Boolean(this.config.scim_base_map), // `userBase` / `groupBase` are NOT exposed here because they may vary // per-request via req.user; clients should treat SCIM ids as opaque. }, }; } } export { writeScimErrorFromException }; linagora-ldap-rest-16e557e/src/plugins/scim/types.ts000066400000000000000000000133411522642357000224710ustar00rootroot00000000000000/** * @module plugins/scim/types * @author Xavier Guimard * * SCIM 2.0 type definitions (RFC 7643 / RFC 7644). */ export const SCHEMA_USER = 'urn:ietf:params:scim:schemas:core:2.0:User'; export const SCHEMA_GROUP = 'urn:ietf:params:scim:schemas:core:2.0:Group'; export const SCHEMA_LIST_RESPONSE = 'urn:ietf:params:scim:api:messages:2.0:ListResponse'; export const SCHEMA_ERROR = 'urn:ietf:params:scim:api:messages:2.0:Error'; export const SCHEMA_PATCH_OP = 'urn:ietf:params:scim:api:messages:2.0:PatchOp'; export const SCHEMA_BULK_REQUEST = 'urn:ietf:params:scim:api:messages:2.0:BulkRequest'; export const SCHEMA_BULK_RESPONSE = 'urn:ietf:params:scim:api:messages:2.0:BulkResponse'; export const SCHEMA_SERVICE_PROVIDER_CONFIG = 'urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'; export const SCHEMA_RESOURCE_TYPE = 'urn:ietf:params:scim:schemas:core:2.0:ResourceType'; export const SCHEMA_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:Schema'; export interface Meta { resourceType: 'User' | 'Group'; created?: string; lastModified?: string; location?: string; version?: string; } export interface MultiValued { value: T; display?: string; type?: string; primary?: boolean; $ref?: string; } export interface ScimName { formatted?: string; familyName?: string; givenName?: string; middleName?: string; honorificPrefix?: string; honorificSuffix?: string; } export interface ScimUser { schemas: string[]; id?: string; externalId?: string; userName?: string; name?: ScimName; displayName?: string; nickName?: string; profileUrl?: string; title?: string; userType?: string; preferredLanguage?: string; locale?: string; timezone?: string; active?: boolean; password?: string; emails?: MultiValued[]; phoneNumbers?: MultiValued[]; ims?: MultiValued[]; photos?: MultiValued[]; addresses?: ScimAddress[]; groups?: MultiValued[]; entitlements?: MultiValued[]; roles?: MultiValued[]; x509Certificates?: MultiValued[]; meta?: Meta; [ext: string]: unknown; } export interface ScimAddress { formatted?: string; streetAddress?: string; locality?: string; region?: string; postalCode?: string; country?: string; type?: string; primary?: boolean; } export interface ScimGroup { schemas: string[]; id?: string; externalId?: string; displayName?: string; members?: MultiValued[]; meta?: Meta; [ext: string]: unknown; } export type ScimResource = ScimUser | ScimGroup; export interface ListResponse { schemas: [typeof SCHEMA_LIST_RESPONSE]; totalResults: number; startIndex: number; itemsPerPage: number; Resources: T[]; } export type ScimErrorType = | 'invalidFilter' | 'tooMany' | 'uniqueness' | 'mutability' | 'invalidSyntax' | 'invalidPath' | 'noTarget' | 'invalidValue' | 'invalidVers' | 'sensitive'; export interface ScimErrorResponse { schemas: [typeof SCHEMA_ERROR]; status: string; scimType?: ScimErrorType; detail?: string; } export interface PatchOperation { op: 'add' | 'remove' | 'replace' | 'Add' | 'Remove' | 'Replace'; path?: string; value?: unknown; } export interface PatchRequest { schemas: string[]; Operations: PatchOperation[]; } export interface BulkOperationRequest { method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'; bulkId?: string; version?: string; path: string; data?: unknown; } export interface BulkRequest { schemas: string[]; failOnErrors?: number; Operations: BulkOperationRequest[]; } export interface BulkOperationResponse { method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'; bulkId?: string; version?: string; location?: string; status: string; response?: ScimErrorResponse; } export interface BulkResponse { schemas: [typeof SCHEMA_BULK_RESPONSE]; Operations: BulkOperationResponse[]; } /** * Internal mapping description: SCIM attribute → LDAP attribute(s). * * - ldap: one LDAP attribute name (simple mapping) * - ldapPrimary/ldapSecondary: primary vs non-primary in multi-valued attr (emails) * - sub: nested SCIM attribute name (for name.familyName etc.) * - multi: 'single' or 'array' (hint for formatting) * - operational: read-only from LDAP operational attribute */ export interface MappingEntry { scim: string; ldap?: string; ldapPrimary?: string; ldapSecondary?: string; sub?: Record; multi?: 'single' | 'array'; readOnly?: boolean; operational?: boolean; converter?: 'boolean' | 'number' | 'string'; } export interface ResourceMapping { resourceType: 'User' | 'Group'; schemas: string[]; entries: MappingEntry[]; } export interface ServiceProviderConfig { schemas: [typeof SCHEMA_SERVICE_PROVIDER_CONFIG]; documentationUri?: string; patch: { supported: boolean }; bulk: { supported: boolean; maxOperations: number; maxPayloadSize: number; }; filter: { supported: boolean; maxResults: number }; changePassword: { supported: boolean }; sort: { supported: boolean }; etag: { supported: boolean }; authenticationSchemes: Array<{ name: string; description: string; specUri?: string; documentationUri?: string; type: string; primary?: boolean; }>; meta?: { resourceType: 'ServiceProviderConfig'; location?: string }; } export interface ResourceTypeDefinition { schemas: [typeof SCHEMA_RESOURCE_TYPE]; id: string; name: string; endpoint: string; description?: string; schema: string; schemaExtensions?: Array<{ schema: string; required: boolean }>; meta?: { resourceType: 'ResourceType'; location?: string }; } export interface SchemaDefinition { schemas: [typeof SCHEMA_SCHEMA]; id: string; name: string; description?: string; attributes: unknown[]; meta?: { resourceType: 'Schema'; location?: string }; } linagora-ldap-rest-16e557e/src/plugins/scim/users.ts000066400000000000000000000307501522642357000224710ustar00rootroot00000000000000/** * @module plugins/scim/users * @author Xavier Guimard * * SCIM Users resource handler — direct access via ldapActions. */ import type winston from 'winston'; import type ldapActions from '../../lib/ldapActions'; import type { AttributesList, ModifyRequest, SearchResult, } from '../../lib/ldapActions'; import type { Config } from '../../config/args'; import type { DmRequest } from '../../lib/auth/base'; import { escapeDnValue, escapeLdapFilter, isChildOf, launchHooks, launchHooksChained, validateDnValue, } from '../../lib/utils'; import { BaseResolver } from './baseResolver'; import { type ResourceMapping, type ScimUser, type ListResponse, type PatchRequest, SCHEMA_LIST_RESPONSE, } from './types'; import { DEFAULT_USER_MAPPING, loadMappingFile, mergeMapping, ldapToScimUser, scimUserToLdap, requiredLdapAttributes, type MappingContext, } from './mapping'; import { scimFilterToLdap } from './filter'; import { patchToModifyRequest } from './patch'; import { scimInvalidValue, scimNotFound, scimTooMany, scimUniqueness, ScimError, extractLdapCode, } from './errors'; export interface ListQuery { filter?: string; startIndex?: number; count?: number; attributes?: string[]; excludedAttributes?: string[]; sortBy?: string; sortOrder?: 'ascending' | 'descending'; } export interface ScimUsersOptions { ldap: ldapActions; config: Config; logger: winston.Logger; baseResolver: BaseResolver; // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type hooks: { [K: string]: Function[] | undefined }; } export class ScimUsers { private readonly ldap: ldapActions; private readonly config: Config; private readonly logger: winston.Logger; private readonly baseResolver: BaseResolver; // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type private readonly hooks: { [K: string]: Function[] | undefined }; private readonly mapping: ResourceMapping; private readonly rdnAttribute: string; private readonly objectClass: string[]; private readonly idAttribute: string; private readonly maxResults: number; private readonly scimPrefix: string; constructor(opts: ScimUsersOptions) { this.ldap = opts.ldap; this.config = opts.config; this.logger = opts.logger; this.baseResolver = opts.baseResolver; this.hooks = opts.hooks; this.rdnAttribute = (this.config.scim_user_rdn_attribute as string) || 'uid'; this.objectClass = this.config.scim_user_object_class as string[]; this.idAttribute = (this.config.scim_id_attribute as string) || 'rdn'; this.maxResults = (this.config.scim_max_results as number) || 200; this.scimPrefix = (this.config.scim_prefix as string) || '/scim/v2'; const override = (this.config.scim_user_mapping as string) || ''; this.mapping = mergeMapping( DEFAULT_USER_MAPPING, override ? loadMappingFile(override) : undefined ); } private ctx(req?: DmRequest): MappingContext { return { idAttribute: this.idAttribute, rdnAttribute: this.rdnAttribute, resourceType: 'User', baseUrl: (this.config.scim_base_url as string) || (req?.protocol && req.get ? `${req.protocol}://${String(req.get('host') || '')}` : ''), scimPrefix: this.scimPrefix, }; } private dnForId(id: string, req?: DmRequest): string { const base = this.baseResolver.userBase(req); return `${this.rdnAttribute}=${escapeDnValue(id)},${base}`; } async get(req: DmRequest, id: string): Promise { const dn = this.dnForId(id, req); let result: SearchResult; try { result = (await this.ldap.search( { paged: false, scope: 'base', attributes: requiredLdapAttributes(this.mapping), }, dn )) as SearchResult; } catch (err) { if (extractLdapCode(err) === 32) { throw scimNotFound(`User ${id} not found`); } throw err; } if (!result.searchEntries || result.searchEntries.length === 0) { throw scimNotFound(`User ${id} not found`); } return ldapToScimUser( result.searchEntries[0] as AttributesList, this.mapping, this.ctx(req) ); } async list( req: DmRequest, query: ListQuery ): Promise> { const base = this.baseResolver.userBase(req); const startIndex = Math.max(1, query.startIndex || 1); const count = Math.min( this.maxResults, Math.max(0, query.count ?? this.maxResults) ); let ldapFilter = `(${this.objectClass.includes('inetOrgPerson') ? 'objectClass=inetOrgPerson' : `objectClass=${this.objectClass[0]}`})`; let idEquals: string | undefined; if (query.filter) { const translated = scimFilterToLdap(query.filter, this.mapping); if (translated.idEquals) { idEquals = translated.idEquals; } else { ldapFilter = `(&${ldapFilter}${translated.ldapFilter})`; } } if (idEquals) { // Short-circuit: id eq "..." → direct base-scope lookup try { const user = await this.get(req, idEquals); return { schemas: [SCHEMA_LIST_RESPONSE], totalResults: 1, startIndex: 1, itemsPerPage: 1, Resources: [user], }; } catch (err) { if (err instanceof ScimError && err.statusCode === 404) { return { schemas: [SCHEMA_LIST_RESPONSE], totalResults: 0, startIndex: 1, itemsPerPage: 0, Resources: [], }; } throw err; } } const result = (await this.ldap.search( { filter: ldapFilter, scope: 'sub', paged: false, attributes: requiredLdapAttributes(this.mapping), sizeLimit: this.maxResults + 1, }, base )) as SearchResult; const entries = result.searchEntries || []; const total = entries.length; if (total > this.maxResults) { throw scimTooMany( `Filter matched ${total} resources, exceeds max ${this.maxResults}` ); } const page = entries.slice(startIndex - 1, startIndex - 1 + count); // sortBy / sortOrder are parsed for backwards compatibility but not // applied — see ServiceProviderConfig.sort.supported = false. Full sort // support would require mapping SCIM paths back to LDAP attributes and // issuing a server-side ordered search. void query.sortBy; void query.sortOrder; const resources = page.map(e => ldapToScimUser(e as AttributesList, this.mapping, this.ctx(req)) ); return { schemas: [SCHEMA_LIST_RESPONSE], totalResults: total, startIndex, itemsPerPage: resources.length, Resources: resources, }; } async create(req: DmRequest, resource: ScimUser): Promise { if (!resource.userName) { throw scimInvalidValue('userName is required'); } const hookInput = await launchHooksChained(this.hooks.scimusercreate, [ resource, req, ] as [ScimUser, DmRequest]); const user = hookInput[0]; const { rdn, attributes } = scimUserToLdap( user, this.mapping, this.ctx(req), this.objectClass ); if (!rdn) throw scimInvalidValue('userName is required'); validateDnValue(rdn, this.rdnAttribute); attributes[this.rdnAttribute] = rdn; const base = this.baseResolver.userBase(req); const dn = `${this.rdnAttribute}=${escapeDnValue(rdn)},${base}`; try { await this.ldap.add(dn, attributes, req); } catch (err) { if (extractLdapCode(err) === 68) { throw scimUniqueness(`User ${rdn} already exists`); } throw err; } const created = await this.get(req, rdn); void launchHooks(this.hooks.scimusercreatedone, created); return created; } async replace( req: DmRequest, id: string, resource: ScimUser ): Promise { const dn = this.dnForId(id, req); // Fetch current LDAP entry so we only delete attributes that actually // exist on the entry (avoids noSuchAttribute errors on atomic modify). const currentResult = (await this.ldap.search( { paged: false, scope: 'base', attributes: requiredLdapAttributes(this.mapping), }, dn )) as SearchResult; if (!currentResult.searchEntries?.length) { throw scimNotFound(`User ${id} not found`); } const currentEntry = currentResult.searchEntries[0] as AttributesList; const hookInput = await launchHooksChained(this.hooks.scimuserupdate, [ id, resource, req, ] as [string, ScimUser, DmRequest]); const incoming = hookInput[1]; const { attributes } = scimUserToLdap( incoming, this.mapping, this.ctx(req), this.objectClass ); const changes: ModifyRequest = { replace: {}, delete: [] }; const skipAttrs = new Set([ 'objectClass', 'entryUUID', 'createTimestamp', 'modifyTimestamp', this.rdnAttribute, ]); const hasAttrValue = (v: unknown): boolean => { if (v == null) return false; if (typeof v === 'string') return v.length > 0; if (Array.isArray(v)) return v.length > 0 && v.some(x => x != null); return true; }; for (const attr of requiredLdapAttributes(this.mapping)) { if (skipAttrs.has(attr)) continue; if (attributes[attr] != null) { changes.replace![attr] = attributes[attr]; } else if (hasAttrValue(currentEntry[attr])) { (changes.delete as string[]).push(attr); } } if (Object.keys(changes.replace || {}).length === 0) delete changes.replace; if ((changes.delete as string[]).length === 0) delete changes.delete; if (changes.replace || changes.delete) { try { await this.ldap.modify(dn, changes, req); } catch (err) { if (extractLdapCode(err) !== 16) throw err; } } const updated = await this.get(req, id); void launchHooks(this.hooks.scimuserupdatedone, id, updated); return updated; } async patch( req: DmRequest, id: string, patch: PatchRequest ): Promise { await this.get(req, id); // ensure exists const changes = await patchToModifyRequest(patch, { mapping: this.mapping, }); if ( !changes.add && !changes.replace && (!changes.delete || (Array.isArray(changes.delete) && changes.delete.length === 0)) ) { return this.get(req, id); } const dn = this.dnForId(id, req); await this.ldap.modify(dn, changes); const updated = await this.get(req, id); void launchHooks(this.hooks.scimuserupdatedone, id, updated); return updated; } async delete(req: DmRequest, id: string): Promise { await this.get(req, id); // ensure exists const hookInput = await launchHooksChained(this.hooks.scimuserdelete, [ id, req, ] as [string, DmRequest]); const finalId = hookInput[0]; const dn = this.dnForId(finalId, req); await this.ldap.delete(dn, req); void launchHooks(this.hooks.scimuserdeletedone, finalId); } /** * Given a SCIM member value (typically an id), return the LDAP DN. * Used by Groups PATCH and Bulk reference resolution. */ async resolveRef(req: DmRequest, value: string): Promise { if (!value) return undefined; const base = this.baseResolver.userBase(req); // Client-supplied DN: only accept when it falls under the tenant's base, // otherwise a client could reference an entry outside its own subtree. if (value.includes('=') && value.includes(',')) { if (isChildOf(value, base)) return value; return undefined; } const dn = `${this.rdnAttribute}=${escapeDnValue(value)},${base}`; try { const res = (await this.ldap.search( { paged: false, scope: 'base', attributes: ['dn'] }, dn )) as SearchResult; if (res.searchEntries && res.searchEntries.length > 0) { return res.searchEntries[0].dn; } } catch { // not found by DN; try a filter } try { const res = (await this.ldap.search( { filter: `(${this.rdnAttribute}=${escapeLdapFilter(value)})`, scope: 'sub', paged: false, attributes: ['dn'], }, base )) as SearchResult; if (res.searchEntries && res.searchEntries.length > 0) { return res.searchEntries[0].dn; } } catch { // ignore } return undefined; } get userMapping(): ResourceMapping { return this.mapping; } } linagora-ldap-rest-16e557e/src/plugins/static.ts000066400000000000000000000165371522642357000216730ustar00rootroot00000000000000/** * @module core/static * Serve static files and JSON schemas * * This plugin serves static files from a specified directory. * It provides access to JSON schemas if stored in a "schemas" subdirectory * and modify them on-the-fly to replace __FOO_BAR__ by --foo-bar value. * * This permits to share the same schemas between server and JS embedded in web pages. * @author Xavier Guimard */ import fs from 'fs'; import { join, resolve } from 'path'; import type { Express } from 'express'; import express from 'express'; import DmPlugin, { type Role } from '../abstract/plugin'; import { notFound } from '../lib/expressFormatedResponses'; import { transformSchemas } from '../lib/utils'; /** * @openapi-component * LdapSchema: * type: object * description: | * A JSON schema file served from the `schemas/` sub-directory of the * static path. These schemas describe LDAP entity types (users, groups, * organizations, …) and are consumed by browser-embedded editors to * render attribute forms and validate input before submission. * * The server replaces `__FOO_BAR__` placeholders on the fly with the * value of the corresponding `--foo-bar` CLI option so that a single * schema file can be shared between server-side validation and * client-side rendering. * additionalProperties: true * example: * entity: * name: standardUser * mainAttribute: uid * objectClass: [top, inetOrgPerson] * singularName: user * pluralName: users * base: ou=users,dc=example,dc=com * strict: true * attributes: * uid: * type: string * required: true * role: identifier * cn: * type: string * required: true */ export default class Static extends DmPlugin { name: string = 'static'; roles: Role[] = ['api', 'configurable'] as const; api(app: Express): void { const rep = this.config.static_path; if (!rep) throw new Error('--static-path is not defined'); try { const stat = fs.statSync(rep); if (!stat.isDirectory()) throw new Error(`${rep} isn't a directory`); fs.accessSync(rep, fs.constants.R_OK); } catch (e) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Bad directory ${rep}: ${e}`); } /** * @openapi * summary: Get JSON schema by name * description: | * Returns a JSON schema file from the top-level `schemas/` directory * of the configured static path. The `:name` segment must match * `[\w-]+\.json` (alphanumerics, hyphens, `.json` extension). * * Schema files may contain `__FOO_BAR__` placeholders that are * substituted at serve-time with the value of the corresponding * `--foo-bar` server option, allowing the same file to be used for * both server-side validation and browser-side form rendering. * responses: * '200': * description: JSON schema file. * content: * application/json: * schema: { $ref: '#/components/schemas/LdapSchema' } * example: * entity: * name: standardUser * mainAttribute: uid * objectClass: [top, inetOrgPerson] * base: ou=users,dc=example,dc=com * strict: true * attributes: * uid: { type: string, required: true, role: identifier } * cn: { type: string, required: true } * '400': * description: Invalid schema name (must match `[\w-]+\.json`). * '404': * description: Schema file not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ app.get(`/${this.config.static_name}/schemas/:name`, (req, res) => { if (!/^[\w-]+\.json$/.test(req.params.name)) { return res.status(400).send('Invalid schema name'); } const schemaPath = resolve(join(rep, 'schemas', req.params.name)); const schemasDir = resolve(join(rep, 'schemas')); // Prevent path traversal by ensuring resolved path is within schemas directory if (!schemaPath.startsWith(schemasDir + '/')) { return res.status(403).send('Access denied'); } fs.readFile(schemaPath, (err, data) => { if (err) { return notFound(res, 'Schema not found'); } const str = transformSchemas(data, this.config); res.type('json').send(str); }); }); /** * @openapi * summary: Get JSON schema from subdirectory * description: | * Returns a JSON schema from a named sub-directory of `schemas/`. * Both `:dir` (alphanumerics and hyphens) and `:name` (`[\w-]+\.json`) * are validated before the file-system path is resolved, and the * resolved path is checked against the schemas root to prevent * path-traversal attacks. * * Available sub-directories depend on the static path configured at * startup. Typical deployments include `standard`, `twake`, `ad`, * `scim`, and `obm`. * responses: * '200': * description: JSON schema file from the sub-directory. * content: * application/json: * schema: { $ref: '#/components/schemas/LdapSchema' } * example: * entity: * name: twakeUser * mainAttribute: uid * objectClass: [top, inetOrgPerson, twakePerson] * base: ou=users,dc=example,dc=com * strict: false * attributes: * uid: { type: string, required: true, role: identifier } * mail: { type: string, required: true } * '400': * description: Invalid directory or schema name. * '404': * description: Schema file not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ app.get(`/${this.config.static_name}/schemas/:dir/:name`, (req, res) => { if ( !/^[\w-]+$/.test(req.params.dir) || !/^[\w-]+\.json$/.test(req.params.name) ) { return res.status(400).send('Invalid schema name'); } const schemaPath = resolve( join(rep, 'schemas', req.params.dir, req.params.name) ); const schemasDir = resolve(join(rep, 'schemas')); // Prevent path traversal by ensuring resolved path is within schemas directory if (!schemaPath.startsWith(schemasDir + '/')) { return res.status(403).send('Access denied'); } fs.readFile(schemaPath, (err, data) => { if (err) { return notFound(res, 'Schema not found'); } const str = transformSchemas(data, this.config); res.type('json').send(str); }); }); app.use(`/${this.config.static_name}`, express.static(rep)); } /** * Provide configuration for config API */ getConfigApiData(): Record { const staticName = this.config.static_name || 'static'; const staticPath = `/${staticName}`; return { enabled: true, staticPath, endpoints: { schema: `${staticPath}/schemas/:name`, schemaInSubdir: `${staticPath}/schemas/:dir/:name`, files: `${staticPath}/*`, }, }; } } linagora-ldap-rest-16e557e/src/plugins/twake/000077500000000000000000000000001522642357000211335ustar00rootroot00000000000000linagora-ldap-rest-16e557e/src/plugins/twake/appAccountsApi.ts000066400000000000000000000622471522642357000244300ustar00rootroot00000000000000/** * @module plugins/twake/appAccountsApi * * API plugin for managing applicative accounts (device/app accounts) * Provides endpoints for listing, creating, and deleting applicative accounts */ import { randomInt } from 'crypto'; import type { Express, Request, Response } from 'express'; import DmPlugin, { type Role } from '../../abstract/plugin'; import type { DM } from '../../bin'; import type { SearchResult } from '../../lib/ldapActions'; import { wantJson } from '../../lib/expressFormatedResponses'; import { escapeDnValue, escapeLdapFilter, isDnInBranch } from '../../lib/utils'; /** * @openapi-component * AppAccount: * type: object * description: | * An application-specific identity attached to a user. App accounts * allow a single principal user to authenticate as different named * "devices" or "applications" each with its own credential, while * sharing the same mail address. * required: [uid] * properties: * uid: * type: string * description: | * Unique identifier of the app account. Format: * `_c<8-digits>`, where `` is the `:user` path param * (the resolution value — the mail by default) sanitized into a * uid-safe token (e.g. `alice@example.com` -> `alice_example_com`). * example: alice_example_com_c04729183 * name: * type: string * description: Human-readable label (stored as LDAP `description`). * example: Work laptop * example: * uid: alice_c04729183 * name: Work laptop * AppAccountCreated: * type: object * description: | * Response returned when a new app account is successfully created. * The one-time cleartext password is included here and never returned * again — callers must store it immediately. * required: [uid, pwd, mail] * properties: * uid: * type: string * description: Identifier of the newly created app account. * example: alice_c04729183 * pwd: * type: string * description: | * One-time cleartext password generated for this account. * Format: 6 blocks of 4 characters separated by `-` * (e.g. `Ab3@-xYz!-9pQ#-Sv4$-mN8!-pQ5@`). * OpenLDAP hashes it via ppolicy before storage. * example: Ab3@-xYz!-9pQ#-Sv4$-mN8!-pQ5@ * mail: * type: string * description: Mail address of the principal account this app account belongs to. * example: alice@example.com * example: * uid: alice_c04729183 * pwd: Ab3@-xYz!-9pQ#-Sv4$-mN8!-pQ5@ * mail: alice@example.com * AppAccountCreate: * type: object * description: Request body for creating a new app account. * properties: * name: * type: string * description: Optional human-readable label for the new account. * example: Work laptop * example: * name: Work laptop */ export default class AppAccountsApi extends DmPlugin { name = 'appAccountsApi'; roles: Role[] = ['api', 'configurable'] as const; dependencies = { appAccountsConsistency: 'core/twake/appAccountsConsistency', }; private applicativeAccountBase: string; private maxAppAccounts: number; private mailAttr: string; // Attribute used to resolve the `:user` path param to the principal entry. // Defaults to the (unique) mail attribute; set to `uid` for the legacy // pre-#89 contract where `:user` is the LDAP uid. The app-account uid is // prefixed from this (unique) `:user` value, sanitized — so with uid it stays // `_c`, and with mail it becomes `_c`. private userAttr: string; constructor(server: DM) { super(server); this.applicativeAccountBase = this.config .applicative_account_base as string; this.maxAppAccounts = (this.config.max_app_accounts as number) || 5; this.mailAttr = (this.config.mail_attribute as string) || 'mail'; this.userAttr = (this.config.app_accounts_user_attribute as string) || this.mailAttr; if (!this.applicativeAccountBase) { throw new Error( `${this.name}: applicative_account_base configuration is required` ); } this.logger.info( `${this.name}: initialized with applicative_account_base=${this.applicativeAccountBase}, max_app_accounts=${this.maxAppAccounts}, user_attribute=${this.userAttr}` ); } /** * Register API routes */ api(app: Express): void { const apiPrefix = this.config.api_prefix || '/api'; /** * @openapi * summary: List app accounts for a user * description: | * Returns all application-specific accounts belonging to `:user`, where * `:user` is the principal account email (globally unique). Ownership is * resolved by the mail attribute, not the LDAP `uid`, which may repeat * under different subtrees of the directory. The principal account * (whose uid equals the mail address) is always filtered out. * * Results are sorted alphabetically by `uid`. * responses: * '200': * description: List of app accounts. * content: * application/json: * schema: * type: array * items: { $ref: '#/components/schemas/AppAccount' } * example: * - uid: alice_c04729183 * name: Work laptop * - uid: alice_c09812345 * name: Mobile phone * '404': * description: User not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // List applicative accounts for a user app.get( `${apiPrefix}/v1/users/:user/app-accounts`, (req: Request, res: Response) => this.listAccounts(req, res) ); /** * @openapi * summary: Create an app account for a user * description: | * Creates a new application-specific identity under the configured * applicative-account LDAP branch. The server generates a random * uid (`_c<8-digits>`, prefix derived from `:user`) and a * cryptographically secure password, then: * * 1. Copies `cn`, `sn`, `givenName`, `mail`, and `displayName` from * the principal user entry. * 2. Adds the cleartext password to the principal account * (`uid=`) so single-point authentication still works. * * Returns a `400` if the user has already reached the server-side * maximum number of app accounts (`max_app_accounts`, default 5). * * **The generated password is returned only once** — store it * immediately. * requestBody: * required: false * content: * application/json: * schema: { $ref: '#/components/schemas/AppAccountCreate' } * example: * name: Work laptop * responses: * '200': * description: App account created — includes the one-time password. * content: * application/json: * schema: { $ref: '#/components/schemas/AppAccountCreated' } * example: * uid: alice_c04729183 * pwd: Ab3@-xYz!-9pQ#-Sv4$-mN8!-pQ5@ * mail: alice@example.com * '400': * description: User has no mail attribute or maximum account limit reached. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } * '404': * description: User not found. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Create a new applicative account app.post( `${apiPrefix}/v1/users/:user/app-accounts`, (req: Request, res: Response) => this.createAccount(req, res) ); /** * @openapi * summary: Delete an app account * description: | * Deletes the app account identified by `:uid` and removes its * password from the principal account (`uid=`). `:user` is the * principal account email. * * The target account's mail must match `:user` — attempting to delete an * account that belongs to a different principal returns `403`. * * The operation is **idempotent**: if the account does not exist, * the response is still `200` with the `uid` echoed back. * responses: * '200': * description: App account deleted (or was already absent). * content: * application/json: * schema: * type: object * properties: * uid: * type: string * description: UID of the deleted account. * example: * uid: alice_c04729183 * '403': * description: The `:uid` does not belong to `:user`. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Delete an applicative account app.delete( `${apiPrefix}/v1/users/:user/app-accounts/:uid`, (req: Request, res: Response) => this.deleteAccount(req, res) ); this.logger.info( `AppAccounts API registered at ${apiPrefix}/v1/users/:user/app-accounts` ); } /** * Resolve the principal user entry from the `:user` path param. * * `:user` is matched against the configured resolution attribute * (`app_accounts_user_attribute`, default the mail attribute). Mail is * globally unique; resolving by the LDAP `uid` instead is only safe when uid * is unique directory-wide, as the same `uid` may repeat under different * subtrees and resolve to an arbitrary same-named user (see #88). * * On failure (no match, or an ambiguous one) this writes the HTTP error * response and returns null, so callers can simply `return` when it does. * * @param principal - The `:user` path param value * @param res - The Express response, used to emit error statuses * @param attributes - Optional attribute projection for the search * @returns The single matching LDAP entry, or null when none/ambiguous */ private async resolvePrincipal( principal: string, res: Response, attributes?: string[] ): Promise { const result = await this.server.ldap.search( { scope: 'sub', filter: `(${this.userAttr}=${escapeLdapFilter(principal)})`, paged: false, ...(attributes ? { attributes } : {}), }, this.config.ldap_base || '' ); // The applicative branch lives under ldap_base and its entries (the // principal account and the app accounts) carry the same mail, so exclude // them: only a real user entry can be the principal. const entries = ((result as SearchResult).searchEntries || []).filter( entry => !isDnInBranch(entry.dn, this.applicativeAccountBase) ); if (entries.length === 0) { res.status(404).json({ error: `User ${principal} not found` }); return null; } if (entries.length > 1) { this.logger.error( `${this.name}: Ambiguous principal lookup for ${principal}: ${entries.length} entries share ${this.userAttr}` ); res.status(409).json({ error: `Multiple users share ${this.userAttr} ${principal}`, }); return null; } return entries[0]; } /** * List applicative accounts for a user */ private async listAccounts(req: Request, res: Response): Promise { if (!wantJson(req, res)) return; const principal = req.params.user as string; try { const userEntry = await this.resolvePrincipal(principal, res, [ this.mailAttr, ]); if (!userEntry) return; const mail = userEntry[this.mailAttr]; if (!mail) { res .status(400) .json({ error: `User ${principal} has no mail attribute` }); return; } const mailStr = Array.isArray(mail) ? String(mail[0]) : String(mail); // Search for applicative accounts with this mail const accountsResult = await this.server.ldap.search( { scope: 'sub', filter: `(${this.mailAttr}=${escapeLdapFilter(mailStr)})`, paged: false, }, this.applicativeAccountBase ); const accounts = (accountsResult as SearchResult).searchEntries || []; // Ownership is the (unique) mail match above; the only non-app entry // sharing it is the principal account (uid=mail), dropped here. Compare // case-insensitively, as LDAP uid/mail matching is. const appAccounts = accounts .filter(entry => { const uid = entry.uid; const uidStr = Array.isArray(uid) ? String(uid[0]) : String(uid); return uidStr.toLowerCase() !== mailStr.toLowerCase(); }) .map(entry => { const uid = entry.uid; const uidStr = Array.isArray(uid) ? String(uid[0]) : String(uid); const desc = entry.description; const descStr = desc ? Array.isArray(desc) ? String(desc[0]) : String(desc) : undefined; return { uid: uidStr, ...(descStr ? { name: descStr } : {}), }; }) .sort((a, b) => a.uid.localeCompare(b.uid)); res.json(appAccounts); } catch (error) { this.logger.error( `${this.name}: Failed to list accounts for ${principal}:`, error ); res.status(500).json({ error: 'Internal server error' }); } } /** * Create a new applicative account */ private async createAccount(req: Request, res: Response): Promise { if (!wantJson(req, res)) return; const principal = req.params.user as string; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { name } = req.body || {}; try { const userEntry = await this.resolvePrincipal(principal, res); if (!userEntry) return; const mail = userEntry[this.mailAttr]; if (!mail) { res .status(400) .json({ error: `User ${principal} has no mail attribute` }); return; } const mailStr = Array.isArray(mail) ? String(mail[0]) : String(mail); // The app-account uid is prefixed from the (unique) `:user` value, not // from the entry's `uid`: the same `uid` may repeat across users, so a // uid-based prefix could collide in the shared applicative branch. The // value is sanitized into a uid-safe token; uniqueness is still enforced // globally below, so a lossy sanitization can never produce a clash. const prefix = this.appUidPrefix(principal); // Enforce the per-user limit, scoped by the unique principal mail. const accountsResult = await this.server.ldap.search( { scope: 'sub', filter: `(${this.mailAttr}=${escapeLdapFilter(mailStr)})`, paged: false, }, this.applicativeAccountBase ); const accounts = (accountsResult as SearchResult).searchEntries || []; const existingAppAccounts = accounts.filter(entry => { const uid = entry.uid; const uidStr = Array.isArray(uid) ? String(uid[0]) : String(uid); return uidStr.toLowerCase() !== mailStr.toLowerCase(); }); if (existingAppAccounts.length >= this.maxAppAccounts) { res.status(400).json({ error: `Maximum number of accounts (${this.maxAppAccounts}) reached`, }); return; } // Generate a uid that is unique across the whole applicative branch (not // just within this principal), so distinct principals can never collide. let newUid: string; let attempts = 0; do { newUid = `${prefix}_${this.generateAccountId()}`; attempts++; if (attempts > 100) { throw new Error( 'Failed to generate unique account ID after 100 attempts' ); } } while (await this.uidExists(newUid)); const newPassword = this.generatePassword(); // Build attributes for new applicative account const newAttrs: Record = { objectClass: ['inetOrgPerson'], uid: newUid, // SECURITY NOTE: Password is passed in cleartext intentionally. // OpenLDAP's ppolicy overlay automatically hashes passwords before storage. // Pre-hashing would fail ppolicy validation (it expects cleartext input). userPassword: newPassword, }; // Copy relevant attributes from user const attrsToCopy = [ 'cn', 'sn', 'givenName', this.mailAttr, 'displayName', ]; for (const attr of attrsToCopy) { if (userEntry[attr]) { const value = userEntry[attr]; // Convert Buffer to string if needed if (Array.isArray(value)) { newAttrs[attr] = value.map(v => Buffer.isBuffer(v) ? v.toString() : String(v) ); } else { newAttrs[attr] = Buffer.isBuffer(value) ? value.toString() : String(value); } } } // Add description if provided if (name) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment newAttrs.description = name; } // Create applicative account const applicativeDn = `uid=${escapeDnValue(newUid)},${this.applicativeAccountBase}`; await this.server.ldap.add(applicativeDn, newAttrs); this.logger.info( `${this.name}: Created applicative account ${applicativeDn} for user ${principal}` ); // Add password to principal account (uid=mail) // The principal account stores all app account passwords for single-point authentication const principalDn = `uid=${escapeDnValue(mailStr)},${this.applicativeAccountBase}`; try { await this.server.ldap.modify(principalDn, { // SECURITY NOTE: Cleartext password is intentional - ppolicy hashes before storage add: { userPassword: newPassword }, }); } catch (error) { this.logger.warn( `${this.name}: Failed to add password to principal account ${principalDn}:`, error ); // Not critical, continue } res.json({ uid: newUid, pwd: newPassword, mail: mailStr }); } catch (error) { this.logger.error( `${this.name}: Failed to create account for ${principal}:`, error ); res.status(500).json({ error: 'Internal server error' }); } } /** * Derive a uid-safe prefix for app-account uids from the (unique) `:user` * value. Characters awkward in a uid/RDN (e.g. `@` and `.` in a mail) are * replaced with `_`. Sanitization may be lossy, but app-account uids are made * unique across the whole branch at creation time, so this never collides. */ private appUidPrefix(principal: string): string { return principal.replace(/[^A-Za-z0-9_-]/g, '_'); } /** * Whether a uid already exists anywhere under the applicative branch. */ private async uidExists(uid: string): Promise { const result = await this.server.ldap.search( { scope: 'sub', filter: `(uid=${escapeLdapFilter(uid)})`, paged: false, attributes: ['uid'], }, this.applicativeAccountBase ); return ((result as SearchResult).searchEntries || []).length > 0; } /** * Delete an applicative account */ private async deleteAccount(req: Request, res: Response): Promise { if (!wantJson(req, res)) return; const principal = req.params.user as string; const uid = req.params.uid as string; try { // Search for the applicative account const accountResult = await this.server.ldap.search( { scope: 'sub', filter: `(uid=${escapeLdapFilter(uid)})`, paged: false, }, this.applicativeAccountBase ); const accountEntry = (accountResult as SearchResult).searchEntries?.[0]; if (!accountEntry) { this.logger.warn( `${this.name}: Account ${uid} already deleted or not found` ); // Return success even if not found (idempotent) res.json({ uid }); return; } // Get mail from account to find principal account const mail = accountEntry[this.mailAttr]; const mailStr = mail ? Array.isArray(mail) ? String(mail[0]) : String(mail) : null; // Ownership: the account's mail must match the principal's mail (the // unique owner key — the uid prefix is not, as the same short uid may // repeat under different subtrees). When `:user` is itself the mail we // compare directly; otherwise we resolve `:user` to the principal entry // and read its mail. let principalMail = principal; if (this.userAttr !== this.mailAttr) { const principalEntry = await this.resolvePrincipal(principal, res, [ this.mailAttr, ]); if (!principalEntry) return; const pm = principalEntry[this.mailAttr]; const pmStr = pm ? Array.isArray(pm) ? String(pm[0]) : String(pm) : null; if (!pmStr) { res .status(400) .json({ error: `User ${principal} has no mail attribute` }); return; } principalMail = pmStr; } if (!mailStr || mailStr.toLowerCase() !== principalMail.toLowerCase()) { res.status(403).json({ error: `Account ${uid} does not belong to user ${principal}`, }); return; } const userPassword = accountEntry.userPassword; if (!userPassword) { this.logger.warn( `${this.name}: Account ${uid} has no userPassword attribute` ); } // Delete password from principal account if available if (userPassword) { const principalDn = `uid=${escapeDnValue(mailStr)},${this.applicativeAccountBase}`; try { const passwordToDelete = Array.isArray(userPassword) ? userPassword[0] : userPassword; await this.server.ldap.modify(principalDn, { delete: { userPassword: passwordToDelete }, }); } catch (error) { this.logger.warn( `${this.name}: Failed to delete password from principal account ${principalDn}:`, error ); // Not critical, continue } } // Delete the applicative account const applicativeDn = accountEntry.dn; await this.server.ldap.delete(applicativeDn); this.logger.info( `${this.name}: Deleted applicative account ${applicativeDn}` ); res.json({ uid }); } catch (error) { this.logger.error( `${this.name}: Failed to delete account ${uid} for ${principal}:`, error ); res.status(500).json({ error: 'Internal server error' }); } } /** * Generate a cryptographically secure random account ID * Format: c + 8 random digits */ private generateAccountId(): string { // Use crypto.randomInt for cryptographically secure random number generation const digits = randomInt(0, 100000000).toString().padStart(8, '0'); return `c${digits}`; } /** * Generate a cryptographically secure random password * Format: 6 blocks of 4 characters separated by "-" * Example: AbC3@-2xYz!-9pQr@-St4v!-mN8#-pQ5$ * Length: 29 characters (6*4 + 5 dashes) * Ensures: uppercase, lowercase, digit, special char in each block * * Note: Password is passed in cleartext to LDAP. OpenLDAP automatically hashes it * via ppolicy overlay before storage. This is the correct approach - attempting to * pre-hash would fail ppolicy validation. */ private generatePassword(): string { const upper = 'ABCDEFGHJKMNPQRSTUVWXYZ'; const lower = 'abcdefghjkmnpqrstuvwxyz'; const digits = '23456789'; const special = '@!#$%'; const blocks: string[] = []; for (let i = 0; i < 6; i++) { // Ensure each block has at least one uppercase, lowercase, digit, special // Use crypto.randomInt for cryptographically secure random selection const upIdx = randomInt(0, upper.length); const lowIdx = randomInt(0, lower.length); const digIdx = randomInt(0, digits.length); const specIdx = randomInt(0, special.length); const chars = [ upper[upIdx], lower[lowIdx], digits[digIdx], special[specIdx], ]; // Shuffle the characters in this block using Fisher-Yates with crypto.randomInt for (let j = chars.length - 1; j > 0; j--) { const k = randomInt(0, j + 1); [chars[j], chars[k]] = [chars[k], chars[j]]; } blocks.push(chars.join('')); } return blocks.join('-'); } /** * Expose configuration for configApi */ getConfigApiData(): Record { const apiPrefix = this.config.api_prefix || '/api'; return { enabled: true, base: this.applicativeAccountBase, maxAccounts: this.maxAppAccounts, mailAttribute: this.mailAttr, endpoints: { list: `${apiPrefix}/v1/users/:user/app-accounts`, create: `${apiPrefix}/v1/users/:user/app-accounts`, delete: `${apiPrefix}/v1/users/:user/app-accounts/:uid`, }, }; } } linagora-ldap-rest-16e557e/src/plugins/twake/appAccountsConsistency.ts000066400000000000000000000402331522642357000262070ustar00rootroot00000000000000/** * App Accounts Consistency Plugin * * Automatically manages applicative account entries for protocol-based authentication * (e.g., IMAP, SMTP, CalDAV, CardDAV). * * ## Concept * * Instead of using a single primary password for all services, this system separates: * - **Primary authentication**: May use passwordless methods (smart cards, biometrics, SSO) * - **Applicative accounts**: Dedicated accounts per device/application * * This is essential for protocols requiring password authentication (IMAP, SMTP, CalDAV) * while maintaining security and allowing easy revocation per device. * * ## Behavior * * When a user with a mail attribute is created, this plugin creates a corresponding * principal applicative account entry in a separate branch (e.g., ou=applicative). * * When a user is deleted, all corresponding applicative accounts are also deleted. * * When a user's mail changes, all applicative accounts are updated with the new mail. */ import DmPlugin from '../../abstract/plugin'; import type { Role } from '../../abstract/plugin'; import type { DM } from '../../bin'; import type { AttributesList, AttributeValue, SearchResult, } from '../../lib/ldapActions'; import { Hooks } from '../../hooks'; import { escapeDnValue, isDnInBranch } from '../../lib/utils'; export default class AppAccountsConsistency extends DmPlugin { name = 'appAccountsConsistency'; roles: Role[] = ['consistency'] as const; dependencies = { onLdapChange: 'core/ldap/onChange', }; // Configuration private mailAttr: string; private applicativeAccountBase: string; private operationalAttributes: string[]; constructor(server: DM) { super(server); // Get configuration this.mailAttr = (this.config.mail_attribute as string) || 'mail'; this.applicativeAccountBase = this.config .applicative_account_base as string; this.operationalAttributes = (this.config.ldap_operational_attribute as string[]) || []; if (!this.applicativeAccountBase) { throw new Error( `${this.name}: applicative_account_base configuration is required` ); } this.logger.info( `${this.name}: initialized with applicative_account_base=${this.applicativeAccountBase}` ); } /** * Check if an attribute should be excluded when copying LDAP entry attributes * @param key - The attribute name to check * @returns true if the attribute should be excluded */ private shouldExcludeAttribute(key: string): boolean { // `dn` is the entry's distinguished name, never a real attribute — exclude // it unconditionally so that a misconfigured operational attribute list // cannot lead to "LDAP add error: UndefinedTypeError: dn" failures. if (key === 'dn') return true; return this.operationalAttributes.includes(key); } /** * Whether a DN belongs to the applicative branch this plugin manages. * * Entries under `applicative_account_base` are *outputs* of this plugin * (principal and app accounts), never source users. Since every LDAP write * re-fires the change hooks — including our own writes — we must ignore * events originating in this branch. Otherwise creating/renaming/deleting an * applicative entry would re-enter `onLdapMailChange` and cause spurious * `AlreadyExists` attempts or a re-entrant deletion cascade during a mail * change. * * @param dn - The DN that changed * @returns true if `dn` is the applicative base itself or sits below it */ private isInApplicativeBranch(dn: string): boolean { // Robust, RDN-by-RDN comparison (case/whitespace/escape-insensitive). A raw // string suffix match can false-negative on real-world DN formatting // differences between the configured base and what the LDAP server returns, // which would let a re-entrant delete event slip through and cascade. return isDnInBranch(dn, this.applicativeAccountBase); } hooks: Hooks = { /** * Handle mail changes, including creation (null → mail) and deletion (mail → null) */ onLdapMailChange: async ( dn: string, oldMail: AttributeValue | null, newMail: AttributeValue | null ) => { // Ignore changes on our own applicative entries: they are managed by // this plugin, never source users. This prevents re-entrant hook firing // (idempotent AlreadyExists churn, or a deletion cascade during a mail // change) when the applicative branch sits under `ldap_base`. if (this.isInApplicativeBranch(dn)) { this.logger.debug( `${this.name}: Ignoring mail change event originating in the applicative branch` ); return; } try { const oldMailStr = oldMail !== null && oldMail !== undefined ? Array.isArray(oldMail) ? String(oldMail[0]) : String(oldMail) : null; const newMailStr = newMail !== null && newMail !== undefined ? Array.isArray(newMail) ? String(newMail[0]) : String(newMail) : null; // Case 1: Creation (null → mail) if (!oldMailStr && newMailStr) { await this.createApplicativeAccount(dn, newMailStr); return; } // Case 2: Deletion (mail → null) if (oldMailStr && !newMailStr) { await this.deleteApplicativeAccount(oldMailStr); return; } // Case 3: Update (mail → newMail) if (oldMailStr && newMailStr && oldMailStr !== newMailStr) { await this.updateApplicativeAccount(dn, oldMailStr, newMailStr); return; } } catch (error) { this.logger.error( `${this.name}: Failed to handle mail change for ${dn}:`, error ); } }, }; /** * Create applicative account for a user */ private async createApplicativeAccount( userDn: string, mail: string ): Promise { const applicativeDn = `uid=${escapeDnValue(mail)},${this.applicativeAccountBase}`; try { // Read user attributes const userResult = await this.server.ldap.search( { scope: 'base', paged: false, }, userDn ); const userEntry = (userResult as SearchResult).searchEntries?.[0]; if (!userEntry) { this.logger.warn( `${this.name}: Could not find user ${userDn} to create applicative account` ); return; } // Create applicative account entry with same attributes but uid changed to mail // Filter out operational attributes and userPassword (let API set passwords separately) const applicativeAttrs: AttributesList = {}; for (const [key, value] of Object.entries(userEntry)) { // Skip operational attributes if (this.shouldExcludeAttribute(key)) { continue; } // Skip empty values if (value === undefined || value === null) { continue; } // Skip empty arrays if (Array.isArray(value) && value.length === 0) { continue; } applicativeAttrs[key] = value; } // Update uid to mail applicativeAttrs.uid = mail; await this.server.ldap.add(applicativeDn, applicativeAttrs); this.logger.info( `${this.name}: Created applicative account ${applicativeDn} for user ${userDn}` ); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { // Ignore AlreadyExistsError (idempotent) // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call if (error.code === 0x44 || error.message?.includes('AlreadyExists')) { this.logger.debug( `${this.name}: Applicative account ${applicativeDn} already exists` ); return; } this.logger.error( `${this.name}: Failed to create applicative account for ${userDn}:`, error ); } } /** * Delete applicative account by mail */ private async deleteApplicativeAccount(mail: string): Promise { try { // Search for applicative accounts by mail attribute const filter = `(${this.mailAttr}=${mail})`; const result = await this.server.ldap.search( { filter, scope: 'sub', paged: false, }, this.applicativeAccountBase ); const searchEntries = (result as SearchResult).searchEntries || []; if (searchEntries.length === 0) { this.logger.debug( `${this.name}: No applicative accounts found for mail ${mail}` ); return; } // Delete all found applicative accounts for (const entry of searchEntries) { const applicativeDn = entry.dn; try { await this.server.ldap.delete(applicativeDn); this.logger.info( `${this.name}: Deleted applicative account ${applicativeDn}` ); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (deleteError: any) { // Ignore NoSuchObjectError (already deleted - idempotent) if ( // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access deleteError.code === 0x20 || // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access deleteError.message?.includes('NoSuchObject') ) { this.logger.debug( `${this.name}: Applicative account ${applicativeDn} already deleted` ); } else { this.logger.error( `${this.name}: Failed to delete applicative account ${applicativeDn}:`, deleteError ); } } } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { // Ignore NoSuchObjectError if the applicative account base doesn't exist // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call if (error.code === 0x20 || error.message?.includes('NoSuchObject')) { this.logger.debug( `${this.name}: Applicative account base does not exist or no accounts found for mail ${mail}` ); return; } this.logger.error( `${this.name}: Failed to delete applicative account for mail ${mail}:`, error ); } } /** * Update applicative account when mail changes */ private async updateApplicativeAccount( userDn: string, oldMail: string, newMail: string ): Promise { try { // Search for the old applicative account const filter = `(${this.mailAttr}=${oldMail})`; const result = await this.server.ldap.search( { filter, scope: 'sub', paged: false, }, this.applicativeAccountBase ); const searchEntries = (result as SearchResult).searchEntries || []; if (searchEntries.length === 0) { this.logger.debug( `${this.name}: No applicative account found for old mail ${oldMail}` ); return; } for (const entry of searchEntries) { const oldApplicativeDn = entry.dn; const oldUid = Array.isArray(entry.uid) ? String(entry.uid[0]) : String(entry.uid); // Distinguish between principal account (uid=mail) and applicative accounts (uid=username_cXXXXXXXX) const isPrincipalAccount = oldUid === oldMail; // Compute new DN based on account type let newApplicativeDn: string; if (isPrincipalAccount) { newApplicativeDn = `uid=${escapeDnValue(newMail)},${this.applicativeAccountBase}`; } else { // Keep the same uid for applicative accounts newApplicativeDn = `uid=${escapeDnValue(oldUid)},${this.applicativeAccountBase}`; } try { // Save old applicative account entry attributes before deletion // This preserves attributes like description that don't exist in user entry const oldApplicativeAttrs: AttributesList = {}; for (const [key, value] of Object.entries(entry)) { // Skip operational attributes if (this.shouldExcludeAttribute(key)) { continue; } if (value === undefined || value === null) { continue; } if (Array.isArray(value) && value.length === 0) { continue; } oldApplicativeAttrs[key] = value; } // Delete old applicative account try { await this.server.ldap.delete(oldApplicativeDn); this.logger.info( `${this.name}: Deleted old applicative account ${oldApplicativeDn}` ); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (deleteError: any) { // Ignore NoSuchObjectError (already deleted) if ( // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access deleteError.code === 0x20 || // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access deleteError.message?.includes('NoSuchObject') ) { this.logger.debug( `${this.name}: Applicative account ${oldApplicativeDn} already deleted` ); } else { throw deleteError; } } // Create new applicative account with updated mail // Start with old applicative account attributes to preserve things like description, userPassword const newAttrs: AttributesList = { ...oldApplicativeAttrs }; // Read the current user entry to get fresh user attributes const userResult = await this.server.ldap.search( { scope: 'base', paged: false, }, userDn ); const userEntry = (userResult as SearchResult).searchEntries?.[0]; if (!userEntry) { this.logger.warn( `${this.name}: Could not find user ${userDn} to update applicative account` ); return; } // Overwrite with fresh user attributes (cn, sn, givenName, mail, etc.) for (const [key, value] of Object.entries(userEntry)) { // Skip operational attributes if (this.shouldExcludeAttribute(key)) { continue; } // Skip empty values if (value === undefined || value === null) { continue; } // Skip empty arrays if (Array.isArray(value) && value.length === 0) { continue; } newAttrs[key] = value; } // For principal account: change uid to newMail // For applicative accounts: keep the same uid (username_cXXXXXXXX) if (isPrincipalAccount) { newAttrs.uid = newMail; } else { // Keep the same uid for applicative accounts newAttrs.uid = oldUid; } await this.server.ldap.add(newApplicativeDn, newAttrs); this.logger.info( `${this.name}: Created new applicative account ${newApplicativeDn} (mail changed from ${oldMail} to ${newMail})` ); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { // Ignore AlreadyExistsError (idempotent) // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call if (error.code === 0x44 || error.message?.includes('AlreadyExists')) { this.logger.debug( `${this.name}: Applicative account ${newApplicativeDn} already exists after mail change` ); return; } this.logger.error( `${this.name}: Failed to update applicative account for mail change:`, error ); } } } catch (error) { this.logger.error( `${this.name}: Failed to update applicative account for ${userDn}:`, error ); } } } linagora-ldap-rest-16e557e/src/plugins/twake/calendarResources.ts000066400000000000000000000321161522642357000251520ustar00rootroot00000000000000import fetch from 'node-fetch'; import TwakePlugin from '../../abstract/twakePlugin'; import { type Role } from '../../abstract/plugin'; import type { AttributesList } from '../../lib/ldapActions'; import type { ChangesToNotify } from '../ldap/onChange'; import { Hooks } from '../../hooks'; /** * Plugin to sync LDAP resources and users with Twake Calendar * * Monitors a LDAP branch for resources (meeting rooms, equipment, etc.) * and automatically creates/updates/deletes them in Twake Calendar via WebAdmin API. * * Also propagates user identity changes (email, first name, last name) to the * Twake Calendar "registered users" via the WebAdmin API. */ export default class CalendarResources extends TwakePlugin { name = 'calendarResources'; roles: Role[] = ['consistency'] as const; dependencies = { onLdapChange: 'core/ldap/onChange', }; // Calendar-specific configuration attributes private resourceBase: string; private resourceObjectClass: string; private resourceCreator: string; private resourceDomain: string; private firstnameAttr: string; private lastnameAttr: string; constructor(server: import('../../bin').DM) { super( server, 'calendar_webadmin_url', 'calendar_webadmin_token', 'calendar_concurrency' ); // Initialize Calendar-specific configuration attributes this.resourceBase = (this.config.calendar_resource_base as string) || ''; this.resourceObjectClass = (this.config.calendar_resource_objectclass as string) || ''; this.resourceCreator = (this.config.calendar_resource_creator as string) || 'admin@example.com'; this.resourceDomain = (this.config.calendar_resource_domain as string) || ''; // LDAP attributes holding the user's first and last name (for registered users) this.firstnameAttr = (this.config.calendar_firstname_attribute as string) || 'givenName'; this.lastnameAttr = (this.config.calendar_lastname_attribute as string) || 'sn'; } hooks: Hooks = { // Hook when a resource is added to LDAP ldapcalendarResourceadddone: async (args: [string, AttributesList]) => { const [dn, attributes] = args; // Only process resources (identified by objectClass or specific branch) if (!this.isResource(dn, attributes)) { return; } const resourceData = this.buildResourceData(dn, attributes); if (!resourceData) { this.logger.debug( `Skipping resource creation for ${dn}: missing required fields` ); return; } await this.callWebAdminApi( 'ldapcalendarResourceadddone', `${this.webadminUrl}/resources`, 'POST', dn, JSON.stringify(resourceData), { resourceName: resourceData.name } ); }, // Hook when a resource is modified in LDAP ldapcalendarResourcemodifydone: async ( args: [ string, { add?: AttributesList; replace?: AttributesList; delete?: string[] | AttributesList; }, number, ] ) => { const [dn, changes] = args; // Check if this is a resource // TODO: fetch objectClass if needed to verify const resourceId = this.getResourceId(dn); if (!resourceId) { return; } // Build update payload from changes const updateData: Partial<{ name: string; description: string; }> = {}; if (changes.replace?.cn) { updateData.name = Array.isArray(changes.replace.cn) ? String(changes.replace.cn[0]) : String(changes.replace.cn); } if (changes.replace?.description) { updateData.description = Array.isArray(changes.replace.description) ? String(changes.replace.description[0]) : String(changes.replace.description); } if (Object.keys(updateData).length === 0) { this.logger.debug(`No relevant changes for resource ${dn}`); return; } await this.callWebAdminApi( 'ldapcalendarResourcemodifydone', `${this.webadminUrl}/resources/${resourceId}`, 'PATCH', dn, JSON.stringify(updateData), { resourceId, ...updateData } ); }, // Hook when a resource is deleted from LDAP ldapcalendarResourcedeletedone: async (dn: string) => { const resourceId = this.getResourceId(dn); if (!resourceId) { return; } await this.callWebAdminApi( 'ldapcalendarResourcedeletedone', `${this.webadminUrl}/resources/${resourceId}`, 'DELETE', dn, null, { resourceId } ); }, /** * Propagate user identity changes to the Calendar registered users. * * A single hook is used (rather than onLdapMailChange / * onLdapDisplayNameChange) so the sync is driven by the *configured* mail, * first name and last name attributes — the display-name hook only fires on * the hard-coded cn/givenName/sn attributes, which would silently ignore a * non-default firstname/lastname attribute. */ onLdapChange: async (dn: string, changes: ChangesToNotify) => { const mailChange = changes[this.mailAttr]; if (mailChange) { const oldmail = this.attributeToString(mailChange[0]); const newmail = this.attributeToString(mailChange[1]); // Only a real rename (old and new both present) updates Calendar. // Additions and deletions of the mail attribute are skipped: there is // nothing to rename on the Calendar side. if (oldmail && newmail && oldmail !== newmail) { // The registered user is keyed by the previous email in Calendar, so // look it up by the old address and PATCH it with the new values. await this.syncRegisteredUser('onLdapMailChange', dn, oldmail); } return; } // Name-only change: sync when a configured name attribute changed. The // email is unchanged, so the user is looked up by its current mail. if (changes[this.firstnameAttr] || changes[this.lastnameAttr]) { await this.syncRegisteredUser('onLdapDisplayNameChange', dn); } }, }; /** * Check if a LDAP entry is a calendar resource */ private isResource(dn: string, attributes: AttributesList): boolean { // Check if DN is under the resources branch if ( this.resourceBase && !dn.toLowerCase().includes(this.resourceBase.toLowerCase()) ) { return false; } // Check for specific objectClass if configured const objectClass = attributes.objectClass; if (this.resourceObjectClass) { const classes = Array.isArray(objectClass) ? objectClass : [objectClass]; return classes.some( cls => String(cls).toLowerCase() === this.resourceObjectClass.toLowerCase() ); } return true; } /** * Build resource data for Calendar API from LDAP attributes */ private buildResourceData( dn: string, attributes: AttributesList ): { name: string; description?: string; creator: string; domain: string; id: string; } | null { // Extract required fields const cn = attributes.cn; const name = this.attributeToString(cn); if (!name) { return null; } // Extract optional description const description = this.attributeToString(attributes.description) || undefined; // Use configured creator or default const creator = this.resourceCreator; // Extract domain from DN or use configured domain const domain = this.resourceDomain || this.extractDomainFromDn(dn); // Generate ID from DN (use cn or uid) const id = this.getResourceId(dn) || name.toLowerCase().replace(/\s+/g, '-'); return { name, description, creator, domain, id, }; } /** * Extract resource ID from DN */ private getResourceId(dn: string): string | null { // Extract cn or uid from DN const match = dn.match(/(?:cn|uid)=([^,]+)/i); return match ? match[1] : null; } /** * Synchronize an LDAP user's identity (email, first and 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 we list all registered users, locate the entry by * email, then `PATCH /registeredUsers?id={id}` with the LDAP values. * * @param event Hook name, used for logging * @param dn LDAP DN of the user * @param lookupEmail Email used to locate the existing registered user. * Defaults to the user's current mail; pass the OLD mail when the email * itself is changing (Calendar still holds the previous address). */ async syncRegisteredUser( event: string, dn: string, lookupEmail?: string ): Promise { const log = { plugin: this.name, event, result: 'error', dn, }; // Fetch the desired identity values from LDAP const entry = await this.ldapGetAttributes(dn, [ this.mailAttr, this.firstnameAttr, this.lastnameAttr, ]); if (!entry) { this.logger.warn({ ...log, message: `Cannot sync registered user: entry not found for ${dn}`, }); return; } const mail = this.attributeToString(entry[this.mailAttr]); if (!mail) { this.logger.warn({ ...log, message: `Cannot sync registered user: no mail found for ${dn}`, }); return; } const firstname = this.attributeToString(entry[this.firstnameAttr]); const lastname = this.attributeToString(entry[this.lastnameAttr]); const searchEmail = lookupEmail || mail; try { // Step 1: list registered users and find the one matching searchEmail const listRes = await this.requestLimit(() => fetch(`${this.webadminUrl}/registeredUsers`, { method: 'GET', headers: this.createHeaders(), }) ); if (!listRes.ok) { this.logger.error({ ...log, step: 'list_registered_users', http_status: listRes.status, http_status_text: listRes.statusText, }); return; } const users = (await listRes.json()) as Array<{ id: string; email: string; firstname?: string; lastname?: string; }>; const existing = users.find( u => u.email?.toLowerCase() === searchEmail.toLowerCase() ); if (!existing) { this.logger.warn({ ...log, step: 'find_registered_user', searchEmail, message: 'user not registered in Calendar', }); return; } // Step 2: PATCH the registered user by id with the LDAP values const patchUrl = new URL(`${this.webadminUrl}/registeredUsers`); patchUrl.searchParams.set('id', existing.id); const payload: { email: string; firstname?: string; lastname?: string; } = { email: mail }; if (firstname) payload.firstname = firstname; if (lastname) payload.lastname = lastname; const patchRes = await this.requestLimit(() => fetch(patchUrl.toString(), { method: 'PATCH', headers: this.createHeaders('application/json'), body: JSON.stringify(payload), }) ); if (!patchRes.ok) { this.logger.error({ ...log, step: 'patch_registered_user', id: existing.id, http_status: patchRes.status, http_status_text: patchRes.statusText, }); } else { this.logger.info({ ...log, result: 'success', id: existing.id, http_status: patchRes.status, ...payload, }); } } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions this.logger.error({ ...log, error: `${err}` }); } } /** * Delete user data from Twake Calendar WebAdmin API * Calls POST /users/{mail}?action=deleteData * @param mail - The user's email address * @returns Task information or null on error */ async deleteUserData(mail: string): Promise<{ taskId: string } | null> { const log = { plugin: this.name, event: 'deleteUserData', mail, }; try { const url = new URL(`${this.webadminUrl}/users/${mail}`); url.searchParams.set('action', 'deleteData'); const response = await this.requestLimit(() => fetch(url.toString(), { method: 'POST', headers: this.createHeaders(), }) ); if (!response.ok) { this.logger.error({ ...log, http_status: response.status, http_status_text: response.statusText, }); return null; } const taskInfo = (await response.json()) as { taskId: string }; this.logger.info({ ...log, http_status: response.status, taskId: taskInfo.taskId, }); return taskInfo; } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions this.logger.error({ ...log, error: `${err}` }); return null; } } } linagora-ldap-rest-16e557e/src/plugins/twake/clouderyProvision.ts000066400000000000000000001102261522642357000252440ustar00rootroot00000000000000/** * @module plugins/twake/clouderyProvision * * B2B sibling of `cozyProvision`. For SCIM users created under a configured * B2B LDAP branch, it provisions a Twake/Cozy instance via the Cloudery * (manager) API, writes the returned FQDN to the `twakeWorkspaceUrl` attribute * (bare FQDN) and the org id to `twakeOrganizationId` (the org id arrives in a * header, so the SCIM body cannot carry it), and publishes the same lifecycle * messages cozyProvision does. * * The org id is carried per-request in a header (single shared B2B token), so * onboarding a new org needs no config change; the org branch is selected by * the core `BaseResolver` (also from a header). The operator supplies the email * in the SCIM body, `org_domain` is its domain, and `slug` = `oidc` = * normalizeNickname(userName) + orgId (dots stripped). * * Alongside the FQDN, the post-create write stamps two attributes the admin * panel reads off the user entry: `twakeOrganizationRole` and (when present) * `twakePhones`. The role is per-request via a header, so a single batch import * can be all members and the next all admins; it accepts `admin`, `owner`, or * `member` and falls back to `cloudery_default_org_role` (`member`) when the * header is absent or unknown. `twakePhones` holds the SCIM phone numbers as the * JSON the panel expects: `[{ "number": "+33...", "primary": true }]`. */ import { setTimeout as sleep } from 'node:timers/promises'; import type { Request } from 'express'; import fetch from 'node-fetch'; import DmPlugin, { type Role, escapeDnValue } from '../../abstract/plugin'; import type { DM } from '../../bin'; import { Hooks } from '../../hooks'; import { isChildOf } from '../../lib/utils'; import type RabbitMq from '../rabbitmq'; import { BaseResolver } from '../scim/baseResolver'; import type { ScimUser } from '../scim/types'; type ReqWithUser = Request & { user?: string }; interface ClouderyInstanceResponse { id: string; fqdn: string; workflow?: string; } interface CreateContext { orgId: string; email: string; role: string; base: string; ts: number; } interface DeleteContext { fqdn: string; domain: string; } // A create that fails between the pre- and post-create hooks would otherwise // leak its captured context; prune anything older than this. const STASH_TTL_MS = 5 * 60 * 1000; // Roles the admin panel recognises for the `twakeOrganizationRole` attribute; // anything else from the header is rejected in favour of the configured default. const VALID_ORG_ROLES = new Set(['admin', 'owner', 'member']); // Cap on the manager API error body we log: enough to carry the actual error // message, bounded so a large HTML error page (repeated across poll attempts) // can't bloat the logs. const MAX_LOG_BODY_LEN = 1000; export default class ClouderyProvision extends DmPlugin { name = 'clouderyProvision'; roles: Role[] = ['consistency'] as const; dependencies = { rabbitmq: 'core/rabbitmq' }; private readonly managerUrl: string; private readonly managerToken: string; private readonly offer: string; private readonly clouderyDomain: string; private readonly userBranch: string; private readonly orgIdHeader: string; private readonly orgRoleHeader: string; private readonly defaultOrgRole: string; private readonly fqdnAttribute: string; private readonly orgIdAttribute: string; private readonly orgRoleAttribute: string; private readonly phonesAttribute: string; private readonly invitedAttribute: string; private readonly defaultLocale: string; private readonly rdnAttribute: string; private readonly authExchange: string; private readonly b2bExchange: string; private readonly userCreatedRoutingKey: string; private readonly userDeletedRoutingKey: string; private readonly pollIntervalMs: number; private readonly maxPollAttempts: number; private readonly baseResolver: BaseResolver; // pre-create hook (has req) → post-create hook (no req). Keyed by the // primary email (unique per user/tenant); userName alone collides across org // branches, which would let one tenant's create overwrite another's context. private readonly createStash = new Map(); // pre-delete hook (entry still exists) → post-delete hook; keyed by id. private readonly deleteStash = new Map(); constructor(server: DM) { super(server); const cfg = this.config; this.managerUrl = ((cfg.cloudery_manager_url as string) || '').replace( /\/$/, '' ); this.managerToken = (cfg.cloudery_manager_token as string) || ''; this.offer = (cfg.cloudery_offer as string) || 'b2b_twake_default'; this.clouderyDomain = (cfg.cloudery_domain as string) || ''; this.userBranch = (cfg.cloudery_user_branch as string) || ''; this.orgIdHeader = ( (cfg.cloudery_org_id_header as string) || 'x-cloudery-org-id' ).toLowerCase(); this.orgRoleHeader = ( (cfg.cloudery_org_role_header as string) || 'x-cloudery-org-role' ).toLowerCase(); // The default is the fallback for an absent/unknown header, so it must be a // valid, lower-cased role itself; otherwise a misconfigured default would // bypass the same validation resolveRole() applies to header values. const configuredRole = (cfg.cloudery_default_org_role as string) || 'member'; const normalizedRole = configuredRole.trim().toLowerCase(); if (VALID_ORG_ROLES.has(normalizedRole)) { this.defaultOrgRole = normalizedRole; } else { this.defaultOrgRole = 'member'; this.logger.warn( `${this.name}: cloudery_default_org_role "${configuredRole}" is not one of ${[ ...VALID_ORG_ROLES, ].join(', ')} — using "member"` ); } this.fqdnAttribute = (cfg.cloudery_fqdn_attribute as string) || 'twakeWorkspaceUrl'; this.orgIdAttribute = (cfg.cloudery_org_id_attribute as string) || 'twakeOrganizationId'; this.orgRoleAttribute = (cfg.cloudery_org_role_attribute as string) || 'twakeOrganizationRole'; this.phonesAttribute = (cfg.cloudery_phones_attribute as string) || 'twakePhones'; this.invitedAttribute = (cfg.cloudery_invited_attribute as string) || 'twakeInvited'; this.defaultLocale = (cfg.cloudery_default_locale as string) || 'en'; this.rdnAttribute = (cfg.scim_user_rdn_attribute as string) || 'uid'; this.authExchange = (cfg.cozy_auth_exchange as string) || 'auth'; this.b2bExchange = (cfg.cozy_b2b_exchange as string) || 'b2b'; this.userCreatedRoutingKey = (cfg.cozy_user_created_routing_key as string) || 'user.created'; this.userDeletedRoutingKey = (cfg.cozy_user_deleted_routing_key as string) || 'domain.user.deleted'; this.pollIntervalMs = Number(cfg.cloudery_workflow_poll_interval_ms) || 2000; this.maxPollAttempts = Number(cfg.cloudery_workflow_max_attempts) || 60; this.baseResolver = new BaseResolver(this.config); if (!this.managerUrl) { this.logger.warn( `${this.name}: cloudery_manager_url is empty — instance provisioning will be skipped` ); } if (!this.userBranch) { this.logger.warn( `${this.name}: cloudery_user_branch is empty — no users will be provisioned` ); } this.logger.info({ plugin: this.name, event: 'init', managerUrl: this.managerUrl || undefined, userBranch: this.userBranch || undefined, orgIdHeader: this.orgIdHeader, orgRoleHeader: this.orgRoleHeader, defaultOrgRole: this.defaultOrgRole, fqdnAttribute: this.fqdnAttribute, orgIdAttribute: this.orgIdAttribute, orgRoleAttribute: this.orgRoleAttribute, phonesAttribute: this.phonesAttribute, invitedAttribute: this.invitedAttribute, pollIntervalMs: this.pollIntervalMs, maxPollAttempts: this.maxPollAttempts, }); } hooks: Hooks = { scimusercreate: ( args: [ScimUser, ReqWithUser?] ): [ScimUser, ReqWithUser?] => { this.normalizeUserName(args[0]); this.captureCreate(args[0], args[1]); return args; }, scimusercreatedone: async (user: ScimUser): Promise => { await this.provision(user); }, scimuserdelete: async ( args: [string, ReqWithUser?] ): Promise<[string, ReqWithUser?]> => { args[0] = args[0].toLowerCase(); await this.captureDelete(args[0], args[1]); return args; }, scimuserdeletedone: async (id: string): Promise => { await this.deprovision(id); }, }; /** Pre-create: stash the request-derived context for the post-create hook. */ private captureCreate(user: ScimUser, req?: ReqWithUser): void { this.pruneStash(); const userName = this.extractId(user); const base = this.baseResolver.userBase(req); this.logger.info({ plugin: this.name, event: 'captureCreate', userName: userName || undefined, base, hasManagerUrl: Boolean(this.managerUrl), hasUserBranch: Boolean(this.userBranch), }); if (!this.managerUrl || !this.userBranch) { this.logger.info({ plugin: this.name, event: 'captureCreate', userName: userName || undefined, base, result: 'skipped', reason: 'missing_configuration', }); return; } if (!userName) { this.logger.info({ plugin: this.name, event: 'captureCreate', base, result: 'skipped', reason: 'missing_user_name', }); return; } if (!this.isAtOrUnder(base, this.userBranch)) { this.logger.info({ plugin: this.name, event: 'captureCreate', userName, base, result: 'skipped', reason: 'outside_b2b_branch', }); return; } const orgId = this.readHeader(req, this.orgIdHeader); if (!orgId) { this.logger.warn({ plugin: this.name, event: 'captureCreate', userName, message: `B2B branch create without ${this.orgIdHeader} — skipping provisioning`, }); return; } // org_domain is the email domain (as in signup), so the email must be in // the body; the core maps it to `mail`. const email = this.extractPrimaryEmail(user); if (!email) { this.logger.warn({ plugin: this.name, event: 'captureCreate', userName, message: 'B2B branch create without a primary email — skipping provisioning', }); return; } // Trim what we stash: leading/trailing whitespace in the email or org id // would otherwise leak into org_domain / internal_email / slug downstream. const cleanEmail = email.trim(); const role = this.resolveRole(req, userName); this.createStash.set(cleanEmail.toLowerCase(), { orgId: orgId.trim(), email: cleanEmail, role, base, ts: Date.now(), }); this.logger.info({ plugin: this.name, event: 'captureCreate', userName, base, orgId: orgId.trim(), email: cleanEmail, role, result: 'stashed', }); } /** * Post-create: provision via Cloudery, wait for the workflow, then write the * FQDN back and publish user.created. Gated on workflow success so nothing is * emitted for an instance that does not exist yet. */ private async provision(user: ScimUser): Promise { const userName = this.extractId(user); this.logger.info({ plugin: this.name, event: 'provision', userName: userName || undefined, }); if (!userName) { this.logger.info({ plugin: this.name, event: 'provision', result: 'skipped', reason: 'missing_user_name', }); return; } const lookupEmail = this.extractPrimaryEmail(user); if (!lookupEmail) { this.logger.info({ plugin: this.name, event: 'provision', userName, result: 'skipped', reason: 'missing_primary_email', }); return; } const key = lookupEmail.trim().toLowerCase(); const ctx = this.createStash.get(key); if (!ctx) { this.logger.info({ plugin: this.name, event: 'provision', userName, email: lookupEmail.trim(), result: 'skipped', reason: 'no_stashed_context', }); return; } this.createStash.delete(key); this.logger.info({ plugin: this.name, event: 'provision', userName, email: ctx.email, orgId: ctx.orgId, base: ctx.base, role: ctx.role, result: 'context_loaded', }); const email = ctx.email; const orgDomain = emailDomain(email); const slug = normalizeNickname(userName) + ctx.orgId; const mobile = this.extractPrimaryPhone(user); const phones = this.extractPhones(user); const publicName = this.extractPublicName(user) || userName; const log = { plugin: this.name, event: 'provision', userName, orgId: ctx.orgId, slug, }; // Idempotency guard. The instance slug is deterministic // (normalizeNickname(userName) + orgId), so its FQDN is `${slug}.${domain}`. // If an instance for that FQDN already exists (e.g. the user is re-imported // after their LDAP entry was recreated while the Cloudery instance survived), // reuse it. Creating unconditionally makes Cloudery mint a numbered // duplicate (slug2). Fail open: if the lookup errors, fall through to create // rather than block provisioning. // Lowercase to the canonical DNS form. Cloudery stores the instance FQDN // lowercased and `normalizeNickname` does not fold case, so a mixed-case // userName would otherwise make this computed key miss the existing instance // (defeating the guard) and, on the reuse path, persist a non-canonical FQDN // that the later deprovision lookup could not resolve. const expectedFqdn = `${slug}.${this.clouderyDomain}`.toLowerCase(); let existingUuid: string | null = null; try { existingUuid = await this.findInstanceUuidByFqdn(expectedFqdn); } catch (err) { this.logger.warn({ ...log, result: 'existence_lookup_failed', fqdn: expectedFqdn, // eslint-disable-next-line @typescript-eslint/restrict-template-expressions error: `${err}`, }); } let fqdn: string; if (existingUuid) { this.logger.info({ ...log, result: 'instance_exists', fqdn: expectedFqdn, uuid: existingUuid, }); fqdn = expectedFqdn; } else { let instance: ClouderyInstanceResponse; try { this.logger.info({ ...log, result: 'creating_instance', orgDomain, email, mobile: mobile || undefined, }); instance = await this.createInstance({ email, publicName, locale: this.extractLocale(user), oidc: slug, phone: mobile || '', slug, public_name: publicName, offer: this.offer, domain: this.clouderyDomain, skip_email_validation: true, internal_email: email, org_domain: orgDomain, org_id: ctx.orgId, }); } catch (err) { this.logger.error({ ...log, result: 'error', // eslint-disable-next-line @typescript-eslint/restrict-template-expressions error: `${err}`, }); return; } const ready = instance.workflow ? await this.waitForWorkflow(instance.workflow) : true; if (!ready) { this.logger.error({ ...log, result: 'workflow_failed', workflow: instance.workflow, }); return; } fqdn = instance.fqdn; } const dn = `${this.rdnAttribute}=${escapeDnValue(userName)},${ctx.base}`; // Role and phones live on the user entry for the admin panel to read; they // are not part of the Cloudery create payload. Phones are stored as the JSON // the panel expects ([{ number, primary }]) and only when present. // twakeInvited marks the member as pending invitation (the string "TRUE", // its schema is a Directory String); the registration app clears it to // "FALSE" once onboarding completes. const replace: Record = { [this.fqdnAttribute]: fqdn, [this.orgIdAttribute]: ctx.orgId, [this.orgRoleAttribute]: ctx.role, [this.invitedAttribute]: 'TRUE', // Force cn to the userName for B2B provisioning; the core SCIM mapping // sets cn from name.formatted, which is not what we want here. cn: userName, }; if (phones.length > 0) { replace[this.phonesAttribute] = JSON.stringify(phones); } try { this.logger.info({ ...log, result: 'writing_fqdn', dn, hasPhones: phones.length > 0, }); await this.server.ldap.modify(dn, { replace }); this.logger.info({ ...log, result: 'written_fqdn', dn, }); } catch (err) { // Instance exists, so still publish; the write failure would break the // later delete lookup, so surface it. this.logger.error({ ...log, event: 'writeFqdn', dn, // eslint-disable-next-line @typescript-eslint/restrict-template-expressions error: `${err}`, }); } await this.publishUserCreated({ twakeId: userName, orgDomain, orgId: ctx.orgId, fqdn, email, mobile, }); this.logger.info({ ...log, result: 'success', fqdn }); } /** * Pre-delete: read the stored FQDN (and domain from mail) while the entry * still exists, so the post-delete hook can tear the instance down. No stored * FQDN means this user was not provisioned here. */ private async captureDelete(id: string, req?: ReqWithUser): Promise { const base = this.baseResolver.userBase(req); this.logger.info({ plugin: this.name, event: 'captureDelete', id, base, hasManagerUrl: Boolean(this.managerUrl), hasUserBranch: Boolean(this.userBranch), }); if (!this.managerUrl || !this.userBranch) { this.logger.info({ plugin: this.name, event: 'captureDelete', id, base, result: 'skipped', reason: 'missing_configuration', }); return; } if (!this.isAtOrUnder(base, this.userBranch)) { this.logger.info({ plugin: this.name, event: 'captureDelete', id, base, result: 'skipped', reason: 'outside_b2b_branch', }); return; } const dn = `${this.rdnAttribute}=${escapeDnValue(id)},${base}`; try { this.logger.info({ plugin: this.name, event: 'captureDelete', id, dn, result: 'searching', }); const res = (await this.server.ldap.search( { paged: false, scope: 'base', attributes: [this.fqdnAttribute, 'mail'], }, dn )) as { searchEntries?: Record[] }; const entry = res.searchEntries?.[0]; const stored = entry ? firstValue(entry[this.fqdnAttribute]) : undefined; const fqdn = stored ? extractFqdn(stored) : undefined; if (!fqdn) { this.logger.info({ plugin: this.name, event: 'captureDelete', id, dn, result: 'skipped', reason: 'missing_fqdn', }); return; } const mail = entry ? firstValue(entry.mail) : undefined; const domain = mail ? emailDomain(mail) : ''; this.deleteStash.set(id, { fqdn, domain, }); this.logger.info({ plugin: this.name, event: 'captureDelete', id, dn, fqdn, domain, result: 'stashed', }); } catch (err) { this.logger.warn({ plugin: this.name, event: 'captureDelete', id, // eslint-disable-next-line @typescript-eslint/restrict-template-expressions error: `${err}`, }); } } /** Post-delete: delete the Cloudery instance by FQDN and publish the event. */ private async deprovision(id: string): Promise { this.logger.info({ plugin: this.name, event: 'deprovision', id, }); const ctx = this.deleteStash.get(id); if (!ctx) { this.logger.info({ plugin: this.name, event: 'deprovision', id, result: 'skipped', reason: 'no_stashed_context', }); return; } this.deleteStash.delete(id); const log = { plugin: this.name, event: 'deprovision', id, fqdn: ctx.fqdn, }; let deleted = false; try { this.logger.info({ ...log, result: 'resolving_uuid' }); const uuid = await this.findInstanceUuidByFqdn(ctx.fqdn); if (uuid) { this.logger.info({ ...log, result: 'deleting_instance', uuid }); await this.deleteInstance(uuid); deleted = true; this.logger.info({ ...log, result: 'deleted', uuid }); } else { this.logger.warn({ ...log, result: 'not_found' }); } } catch (err) { this.logger.error({ ...log, result: 'error', // eslint-disable-next-line @typescript-eslint/restrict-template-expressions error: `${err}`, }); } // Only announce the deletion once the instance is actually destroyed, so a // failed lookup/delete never notifies downstream of a teardown that did not // happen (matching cozyProvision). if (deleted) { await this.publishUserDeleted(ctx.fqdn, ctx.domain); } } /* ----------------------------- Cloudery API ----------------------------- */ private async createInstance( payload: Record ): Promise { this.logger.info({ plugin: this.name, event: 'createInstance', url: `${this.managerUrl}/api/v1/instances`, }); const res = await fetch(`${this.managerUrl}/api/v1/instances`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.managerToken}`, }, body: JSON.stringify(payload), }); if (!res.ok) { const body = (await res.text().catch(() => '')).slice( 0, MAX_LOG_BODY_LEN ); this.logger.error({ plugin: this.name, event: 'createInstance', result: 'error', http_status: res.status, http_status_text: res.statusText, body: body || undefined, }); throw new Error(`Cloudery createInstance HTTP ${res.status}`); } const data = (await res.json()) as ClouderyInstanceResponse; this.logger.info({ plugin: this.name, event: 'createInstance', result: 'success', id: data.id, fqdn: data.fqdn, workflow: data.workflow, }); return data; } /** Poll the workflow until it succeeds. Returns false on failure/timeout. */ private async waitForWorkflow(workflowId: string): Promise { this.logger.info({ plugin: this.name, event: 'waitForWorkflow', workflow: workflowId, maxPollAttempts: this.maxPollAttempts, pollIntervalMs: this.pollIntervalMs, }); for (let attempt = 1; attempt <= this.maxPollAttempts; attempt++) { try { const res = await fetch( `${this.managerUrl}/api/v1/workflows/${encodeURIComponent( workflowId )}`, { headers: { Authorization: `Bearer ${this.managerToken}` } } ); if (res.ok) { const data = (await res.json()) as { status?: string }; this.logger.info({ plugin: this.name, event: 'waitForWorkflow', workflow: workflowId, attempt, status: data.status, }); if (data.status === 'succeeded') { this.logger.info({ plugin: this.name, event: 'waitForWorkflow', workflow: workflowId, attempt, result: 'success', }); return true; } if (data.status === 'failed' || data.status === 'error') { this.logger.warn({ plugin: this.name, event: 'waitForWorkflow', workflow: workflowId, attempt, result: 'failed', status: data.status, }); return false; } } else { const body = (await res.text().catch(() => '')).slice( 0, MAX_LOG_BODY_LEN ); this.logger.info({ plugin: this.name, event: 'waitForWorkflow', workflow: workflowId, attempt, result: 'http_error', http_status: res.status, http_status_text: res.statusText, body: body || undefined, }); } } catch (err) { this.logger.info({ plugin: this.name, event: 'waitForWorkflow', workflow: workflowId, attempt, // eslint-disable-next-line @typescript-eslint/restrict-template-expressions error: `${err}`, }); } if (attempt < this.maxPollAttempts) { this.logger.info({ plugin: this.name, event: 'waitForWorkflow', workflow: workflowId, attempt, result: 'retrying', }); await sleep(this.pollIntervalMs); } } this.logger.info({ plugin: this.name, event: 'waitForWorkflow', workflow: workflowId, result: 'timeout', maxPollAttempts: this.maxPollAttempts, }); return false; } private async findInstanceUuidByFqdn(fqdn: string): Promise { this.logger.info({ plugin: this.name, event: 'findInstanceUuidByFqdn', fqdn, }); const url = new URL(`${this.managerUrl}/api/v2/instances`); url.searchParams.set('fqdn', fqdn); url.searchParams.append('only', '_id'); url.searchParams.set('limit', '1'); const res = await fetch(url.toString(), { headers: { Authorization: `Bearer ${this.managerToken}` }, }); if (!res.ok) { const body = (await res.text().catch(() => '')).slice( 0, MAX_LOG_BODY_LEN ); this.logger.error({ plugin: this.name, event: 'findInstanceUuidByFqdn', fqdn, result: 'error', http_status: res.status, http_status_text: res.statusText, body: body || undefined, }); throw new Error(`Cloudery searchInstances HTTP ${res.status}`); } const data = (await res.json()) as { items?: { _id: string }[] }; const uuid = data.items && data.items.length > 0 ? data.items[0]._id : null; this.logger.info({ plugin: this.name, event: 'findInstanceUuidByFqdn', fqdn, result: uuid ? 'found' : 'not_found', uuid: uuid || undefined, }); return uuid; } private async deleteInstance(uuid: string): Promise { const url = new URL( `${this.managerUrl}/api/v1/instances/${encodeURIComponent(uuid)}` ); url.searchParams.set('user_request', 'true'); this.logger.info({ plugin: this.name, event: 'deleteInstance', uuid, url: url.toString(), }); const res = await fetch(url.toString(), { method: 'DELETE', headers: { Authorization: `Bearer ${this.managerToken}` }, }); if (!res.ok) { const body = (await res.text().catch(() => '')).slice( 0, MAX_LOG_BODY_LEN ); this.logger.error({ plugin: this.name, event: 'deleteInstance', uuid, result: 'error', http_status: res.status, http_status_text: res.statusText, body: body || undefined, }); throw new Error(`Cloudery deleteInstance HTTP ${res.status}`); } this.logger.info({ plugin: this.name, event: 'deleteInstance', uuid, result: 'success', http_status: res.status, }); } /* ------------------------------ Publishing ------------------------------ */ private async publishUserCreated(p: { twakeId: string; orgDomain: string; orgId: string; fqdn: string; email: string | null; mobile: string | null; }): Promise { const rabbitmq = this.requirePlugin('rabbitmq'); this.logger.info({ plugin: this.name, event: 'publishUserCreated', twakeId: p.twakeId, orgId: p.orgId, orgDomain: p.orgDomain, hasRabbitmq: Boolean(rabbitmq), }); if (!rabbitmq) { this.logger.info({ plugin: this.name, event: 'publishUserCreated', twakeId: p.twakeId, result: 'skipped', reason: 'missing_rabbitmq', }); return; } const message: Record = { twakeId: p.twakeId, domain: p.orgDomain, organizationDomain: p.orgDomain, workplaceFqdn: p.fqdn, organizationId: p.orgId, }; if (p.email) message.internalEmail = p.email; if (p.mobile) message.mobile = p.mobile; try { await rabbitmq.publish( this.authExchange, this.userCreatedRoutingKey, message ); this.logger.info({ plugin: this.name, event: 'publishUserCreated', twakeId: p.twakeId, exchange: this.authExchange, routingKey: this.userCreatedRoutingKey, result: 'success', }); } catch (err) { this.logger.error({ plugin: this.name, event: 'publishUserCreated', twakeId: p.twakeId, // eslint-disable-next-line @typescript-eslint/restrict-template-expressions error: `${err}`, }); } } private async publishUserDeleted( fqdn: string, domain: string ): Promise { const rabbitmq = this.requirePlugin('rabbitmq'); this.logger.info({ plugin: this.name, event: 'publishUserDeleted', workplaceFqdn: fqdn, domain, hasRabbitmq: Boolean(rabbitmq), }); if (!rabbitmq) { this.logger.info({ plugin: this.name, event: 'publishUserDeleted', workplaceFqdn: fqdn, result: 'skipped', reason: 'missing_rabbitmq', }); return; } try { await rabbitmq.publish(this.b2bExchange, this.userDeletedRoutingKey, { workplaceFqdn: fqdn, domain, }); this.logger.info({ plugin: this.name, event: 'publishUserDeleted', workplaceFqdn: fqdn, exchange: this.b2bExchange, routingKey: this.userDeletedRoutingKey, result: 'success', }); } catch (err) { this.logger.error({ plugin: this.name, event: 'publishUserDeleted', workplaceFqdn: fqdn, // eslint-disable-next-line @typescript-eslint/restrict-template-expressions error: `${err}`, }); } } /* ------------------------------- Helpers -------------------------------- */ /** Read a request header by name, falling back to a query parameter. */ private readHeader( req: ReqWithUser | undefined, name: string ): string | undefined { if (!req) return undefined; const fromHeader = req.get ? req.get(name) : undefined; if (typeof fromHeader === 'string' && fromHeader.length > 0) { return fromHeader; } const q = req.query?.[name]; return typeof q === 'string' && q.length > 0 ? q : undefined; } /** * Per-request org role from the header (so one batch import can be all admins * and the next all members), validated against {@link VALID_ORG_ROLES}. An * absent or unknown role falls back to the configured default rather than * failing the import. */ private resolveRole(req: ReqWithUser | undefined, userName: string): string { const raw = this.readHeader(req, this.orgRoleHeader)?.trim().toLowerCase(); if (!raw) { this.logger.debug({ plugin: this.name, event: 'resolveRole', userName, result: 'default', role: this.defaultOrgRole, }); return this.defaultOrgRole; } if (!VALID_ORG_ROLES.has(raw)) { this.logger.warn({ plugin: this.name, event: 'captureCreate', userName, message: `Unknown ${this.orgRoleHeader} "${raw}" — falling back to ${this.defaultOrgRole}`, }); this.logger.debug({ plugin: this.name, event: 'resolveRole', userName, input: raw, result: 'default', role: this.defaultOrgRole, }); return this.defaultOrgRole; } this.logger.debug({ plugin: this.name, event: 'resolveRole', userName, input: raw, result: 'accepted', role: raw, }); return raw; } private isAtOrUnder(dn: string, parent: string): boolean { return ( dn.trim().toLowerCase() === parent.trim().toLowerCase() || isChildOf(dn, parent) ); } private pruneStash(): void { const cutoff = Date.now() - STASH_TTL_MS; for (const [key, ctx] of this.createStash) { if (ctx.ts < cutoff) { this.createStash.delete(key); } } } // Lowercase userName in place; the core derives the uid RDN from it after // this hook. Usernames must be lowercase. private normalizeUserName(user: ScimUser): void { if (typeof user.userName === 'string') { user.userName = user.userName.toLowerCase(); } } private extractId(user: ScimUser): string | null { if (typeof user.userName === 'string' && user.userName.length > 0) { return user.userName; } if (typeof user.id === 'string' && user.id.length > 0) return user.id; return null; } private extractPrimaryEmail(user: ScimUser): string | null { const emails = user.emails; if (!emails || emails.length === 0) return null; const primary = emails.find(e => e.primary); const value = (primary || emails[0]).value; return typeof value === 'string' && value.length > 0 ? value : null; } private extractPrimaryPhone(user: ScimUser): string | null { const phones = user.phoneNumbers; if (!phones || phones.length === 0) return null; const primary = phones.find(p => p.primary); const value = (primary || phones[0]).value; return typeof value === 'string' && value.length > 0 ? value : null; } /** All phone numbers, shaped for the `twakePhones` attribute the panel reads. */ private extractPhones( user: ScimUser ): { number: string; primary: boolean }[] { const phones = user.phoneNumbers; if (!phones || phones.length === 0) return []; return phones .filter(p => typeof p.value === 'string' && p.value.trim().length > 0) .map(p => ({ number: p.value.trim(), primary: Boolean(p.primary) })); } private extractPublicName(user: ScimUser): string | null { if ( typeof user.displayName === 'string' && user.displayName.trim().length > 0 ) { return user.displayName.trim(); } const formatted = user.name?.formatted; if (typeof formatted === 'string' && formatted.trim().length > 0) { return formatted.trim(); } const given = user.name?.givenName?.trim() ?? ''; const family = user.name?.familyName?.trim() ?? ''; const composed = `${given} ${family}`.trim(); return composed.length > 0 ? composed : null; } private extractLocale(user: ScimUser): string { if (typeof user.locale === 'string' && user.locale.length > 0) { return user.locale; } if ( typeof user.preferredLanguage === 'string' && user.preferredLanguage.length > 0 ) { return user.preferredLanguage; } return this.defaultLocale; } } /** Strip dots from the nickname to form the slug/oidc identifier. */ function normalizeNickname(nickname: string): string { return nickname.replace(/\./g, ''); } function emailDomain(email: string): string { const at = email.lastIndexOf('@'); return at >= 0 ? email.slice(at + 1) : ''; } /** Reduce a stored workspace value to a bare FQDN, tolerating a protocol/path. */ function extractFqdn(value: string): string { return value .trim() .replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') .split('/')[0]; } /** LDAP attribute values come back as a scalar or an array. */ function firstValue(value: unknown): string | undefined { if (Array.isArray(value)) { const v: unknown = value[0]; return typeof v === 'string' ? v : undefined; } return typeof value === 'string' ? value : undefined; } linagora-ldap-rest-16e557e/src/plugins/twake/cozyProvision.ts000066400000000000000000000365211522642357000244070ustar00rootroot00000000000000/** * @module plugins/twake/cozyProvision * * Provisions a Cozy instance and publishes lifecycle events on RabbitMQ when * users are created or deleted via SCIM. Depends on cozy-stack admin API and * an AMQP broker reachable from the ldap-rest process. * * Hooks: * - scimusercreatedone: POST /instances on cozy admin, then publish * `auth` / `user.created` so downstream services (cozy-stack, etc.) * can react. * - scimuserdeletedone: publish `b2b` / `domain.user.deleted` so peer * instances can clean up the deleted user's contact card. * * AMQP transport is provided by the shared `rabbitmq` plugin (declared as a * dependency). This plugin builds the lifecycle messages and hands them to * that plugin to publish; the broker connection, optional-dependency import, * and "broker absent ⇒ skip" handling all live there. */ import { Buffer } from 'buffer'; import fetch from 'node-fetch'; import DmPlugin, { type Role } from '../../abstract/plugin'; import type { DM } from '../../bin'; import { Hooks } from '../../hooks'; import type RabbitMq from '../rabbitmq'; import type { ScimUser } from '../scim/types'; export default class CozyProvision extends DmPlugin { name = 'cozyProvision'; roles: Role[] = ['consistency'] as const; dependencies = { rabbitmq: 'core/rabbitmq' }; private readonly cozyAdminUrl: string; private readonly cozyAdminPassphrase: string; private readonly cozyAdminUser: string; private readonly cozyOrgId: string; private readonly cozyOrgDomain: string; private readonly cozyDefaultLocale: string; private readonly cozyContextName: string; private readonly cozyApps: string; private readonly authExchange: string; private readonly b2bExchange: string; private readonly userCreatedRoutingKey: string; private readonly userDeletedRoutingKey: string; private readonly cozyAdminAuthHeader: string; constructor(server: DM) { super(server); this.cozyAdminUrl = ((this.config.cozy_admin_url as string) || '').replace( /\/$/, '' ); this.cozyAdminUser = (this.config.cozy_admin_user as string) || 'admin'; this.cozyAdminPassphrase = (this.config.cozy_admin_passphrase as string) || ''; this.cozyOrgId = (this.config.cozy_org_id as string) || ''; this.cozyOrgDomain = (this.config.cozy_org_domain as string) || ''; this.cozyDefaultLocale = (this.config.cozy_default_locale as string) || 'fr'; this.cozyContextName = (this.config.cozy_context_name as string) || 'default'; this.cozyApps = (this.config.cozy_apps as string) ?? ''; this.authExchange = (this.config.cozy_auth_exchange as string) || 'auth'; this.b2bExchange = (this.config.cozy_b2b_exchange as string) || 'b2b'; this.userCreatedRoutingKey = (this.config.cozy_user_created_routing_key as string) || 'user.created'; this.userDeletedRoutingKey = (this.config.cozy_user_deleted_routing_key as string) || 'domain.user.deleted'; this.cozyAdminAuthHeader = `Basic ${Buffer.from( `${this.cozyAdminUser}:${this.cozyAdminPassphrase}` ).toString('base64')}`; if (!this.cozyAdminUrl) { this.logger.warn( `${this.name}: cozy_admin_url is empty — Cozy instance creation will be skipped` ); } if (!this.cozyOrgDomain) { this.logger.warn( `${this.name}: cozy_org_domain is empty — workplaceFqdn cannot be composed` ); } } hooks: Hooks = { scimusercreatedone: async (user: ScimUser): Promise => { const ok = await this.createCozyInstance(user); if (ok) { await this.publishUserCreated(user); } }, scimuserdeletedone: async (id: string): Promise => { const ok = await this.destroyCozyInstance(id); if (ok) { await this.publishUserDeleted(id); } }, }; /** * POST /instances on the cozy-stack admin API. Returns true on success * (including 409 idempotent "already exists"). */ private async createCozyInstance(user: ScimUser): Promise { if (!this.cozyAdminUrl) return false; const id = this.extractId(user); if (!id) { this.logger.warn({ plugin: this.name, event: 'createCozyInstance', message: 'user has no userName/id — skipping', }); return false; } if (!this.cozyOrgDomain) { this.logger.error({ plugin: this.name, event: 'createCozyInstance', id, message: 'cozy_org_domain not configured — cannot compose Domain', }); return false; } const domain = `${id}.${this.cozyOrgDomain}`; const params = new URLSearchParams(); params.set('Domain', domain); params.set('Locale', this.extractLocale(user)); const email = this.extractPrimaryEmail(user); if (email) params.set('Email', email); const publicName = this.extractPublicName(user); if (publicName) params.set('PublicName', publicName); const phone = this.extractPrimaryPhone(user); if (phone) params.set('Phone', phone); if (this.cozyOrgId) params.set('OrgID', this.cozyOrgId); if (this.cozyOrgDomain) params.set('OrgDomain', this.cozyOrgDomain); if (this.cozyContextName) params.set('ContextName', this.cozyContextName); if (this.cozyApps) params.set('Apps', this.cozyApps); // Set the cozy instance's OIDCID to the user's SCIM userName so the // sub claim from the OP after authentication matches the instance and // cozy-stack's OIDC callback succeeds. Without this the callback fails // with `Invalid sub: != ""`. params.set('OIDCID', id); const url = `${this.cozyAdminUrl}/instances?${params.toString()}`; const log = { plugin: this.name, event: 'createCozyInstance', id, domain, email: email || undefined, }; try { const res = await fetch(url, { method: 'POST', headers: { Authorization: this.cozyAdminAuthHeader, Accept: 'application/json', }, }); if (!res.ok) { if (res.status === 409) { this.logger.info({ ...log, result: 'already_exists', http_status: res.status, }); return true; } this.logger.error({ ...log, result: 'error', http_status: res.status, http_status_text: res.statusText, }); return false; } this.logger.info({ ...log, result: 'success', http_status: res.status, }); await this.markInstanceOnboarded(domain); return true; } catch (err) { this.logger.error({ ...log, result: 'error', // eslint-disable-next-line @typescript-eslint/restrict-template-expressions error: `${err}`, }); return false; } } /** * PATCH /instances/?OnboardingFinished=true on the cozy admin API. * * SCIM-provisioned users have no signup flow to complete (the admin already * provisioned them), so the per-user instance should land directly in the * onboarded state. Without this PATCH the instance stays in onboarding and * the user gets dropped onto a setup wizard at first OIDC login. */ private async markInstanceOnboarded(domain: string): Promise { const url = `${this.cozyAdminUrl}/instances/${encodeURIComponent( domain )}?OnboardingFinished=true`; const log = { plugin: this.name, event: 'markInstanceOnboarded', domain, }; try { const res = await fetch(url, { method: 'PATCH', headers: { Authorization: this.cozyAdminAuthHeader, Accept: 'application/json', }, }); if (!res.ok) { this.logger.error({ ...log, result: 'error', http_status: res.status, http_status_text: res.statusText, }); return; } this.logger.info({ ...log, result: 'success', http_status: res.status, }); } catch (err) { this.logger.error({ ...log, result: 'error', // eslint-disable-next-line @typescript-eslint/restrict-template-expressions error: `${err}`, }); } } /** * DELETE /instances/ on the cozy-stack admin API. The SCIM delete * hook only removed the LDAP entry; without this, a subsequent re-import * of the same userName re-attaches to a stale cozy instance whose * per-user state (oidc_id, sharings, contacts, ...) no longer matches * the operator's intent. A 404 from cozy-stack is treated as success so * the lifecycle stays idempotent. */ private async destroyCozyInstance(id: string): Promise { if (!this.cozyAdminUrl) return false; if (!this.cozyOrgDomain) { this.logger.error({ plugin: this.name, event: 'destroyCozyInstance', id, message: 'cozy_org_domain not configured — cannot compose Domain', }); return false; } const domain = `${id}.${this.cozyOrgDomain}`; const url = `${this.cozyAdminUrl}/instances/${encodeURIComponent(domain)}`; const log = { plugin: this.name, event: 'destroyCozyInstance', id, domain, }; try { const res = await fetch(url, { method: 'DELETE', headers: { Authorization: this.cozyAdminAuthHeader, Accept: 'application/json', }, }); if (res.ok || res.status === 404) { this.logger.info({ ...log, result: res.ok ? 'success' : 'not_found', http_status: res.status, }); return true; } this.logger.error({ ...log, result: 'error', http_status: res.status, http_status_text: res.statusText, }); return false; } catch (err) { this.logger.error({ ...log, result: 'error', // eslint-disable-next-line @typescript-eslint/restrict-template-expressions error: `${err}`, }); return false; } } /** * Publish user.created on the `auth` topic exchange. Payload matches what * cozy-stack's stack.user.created consumer expects: a `twakeId` identifier, * the `organizationDomain` so the consumer can match the instance, and * optional `internalEmail` / `mobile` fields used for org-contact-sync. * Sending `sub` instead of `twakeId`, or `email` instead of `internalEmail`, * causes cozy-stack to nack the message. */ private async publishUserCreated(user: ScimUser): Promise { const rabbitmq = this.rabbitmq(); if (!rabbitmq) return; const id = this.extractId(user); if (!id) return; const email = this.extractPrimaryEmail(user); const mobile = this.extractPrimaryPhone(user); const message: Record = { twakeId: id, domain: this.cozyOrgDomain, organizationDomain: this.cozyOrgDomain, workplaceFqdn: `${id}.${this.cozyOrgDomain}`, }; if (email) message.internalEmail = email; if (mobile) message.mobile = mobile; try { await rabbitmq.publish( this.authExchange, this.userCreatedRoutingKey, message ); this.logger.info({ plugin: this.name, event: 'publishUserCreated', result: 'success', exchange: this.authExchange, routingKey: this.userCreatedRoutingKey, twakeId: id, }); } catch (err) { this.logger.error({ plugin: this.name, event: 'publishUserCreated', twakeId: id, // eslint-disable-next-line @typescript-eslint/restrict-template-expressions error: `${err}`, }); } } /** * Publish domain.user.deleted on the `b2b` topic exchange so peer cozy * instances can drop the deleted user's contact card. */ private async publishUserDeleted(id: string): Promise { const rabbitmq = this.rabbitmq(); if (!rabbitmq) return; if (!this.cozyOrgDomain) { this.logger.error({ plugin: this.name, event: 'publishUserDeleted', id, message: 'cozy_org_domain not configured — cannot compose workplaceFqdn', }); return; } const message: Record = { workplaceFqdn: `${id}.${this.cozyOrgDomain}`, domain: this.cozyOrgDomain, }; try { await rabbitmq.publish( this.b2bExchange, this.userDeletedRoutingKey, message ); this.logger.info({ plugin: this.name, event: 'publishUserDeleted', result: 'success', exchange: this.b2bExchange, routingKey: this.userDeletedRoutingKey, workplaceFqdn: message.workplaceFqdn, }); } catch (err) { this.logger.error({ plugin: this.name, event: 'publishUserDeleted', id, // eslint-disable-next-line @typescript-eslint/restrict-template-expressions error: `${err}`, }); } } /** * Resolve the shared `rabbitmq` plugin (declared as a dependency, so it loads * first). Returns null and warns once if it is absent, letting the publish * paths no-op cleanly. */ private rabbitmq(): RabbitMq | null { return this.requirePlugin('rabbitmq'); } private extractId(user: ScimUser): string | null { if (typeof user.userName === 'string' && user.userName.length > 0) { return user.userName; } if (typeof user.id === 'string' && user.id.length > 0) { return user.id; } return null; } private extractPrimaryEmail(user: ScimUser): string | null { const emails = user.emails; if (!emails || emails.length === 0) return null; const primary = emails.find(e => e.primary); const value = (primary || emails[0]).value; return typeof value === 'string' && value.length > 0 ? value : null; } private extractPrimaryPhone(user: ScimUser): string | null { const phones = user.phoneNumbers; if (!phones || phones.length === 0) return null; const primary = phones.find(p => p.primary); const value = (primary || phones[0]).value; return typeof value === 'string' && value.length > 0 ? value : null; } /** * Best-effort display name for the cozy instance's `public_name` setting, * which downstream features (org-contact sync, sharing, mailer, …) read * from the instance settings document. Without it cozy-stack's * SyncCreatedOrgContact bails with "missing public_name in settings". * * Order of preference: SCIM displayName → name.formatted → * givenName + familyName → userName/id. */ private extractPublicName(user: ScimUser): string | null { if ( typeof user.displayName === 'string' && user.displayName.trim().length > 0 ) { return user.displayName.trim(); } const formatted = user.name?.formatted; if (typeof formatted === 'string' && formatted.trim().length > 0) { return formatted.trim(); } const given = user.name?.givenName?.trim() ?? ''; const family = user.name?.familyName?.trim() ?? ''; const composed = `${given} ${family}`.trim(); if (composed.length > 0) return composed; return this.extractId(user); } private extractLocale(user: ScimUser): string { if (typeof user.locale === 'string' && user.locale.length > 0) { return user.locale; } if ( typeof user.preferredLanguage === 'string' && user.preferredLanguage.length > 0 ) { return user.preferredLanguage; } return this.cozyDefaultLocale; } } linagora-ldap-rest-16e557e/src/plugins/twake/drive.ts000066400000000000000000000422761522642357000226270ustar00rootroot00000000000000/** * Twake Drive Plugin * * Synchronizes LDAP user changes with Twake Drive via Admin API. * Propagates email address, display name, and disk quota changes. * * @see https://docs.cozy.io/en/cozy-stack/admin/ */ import fetch from 'node-fetch'; import TwakePlugin from '../../abstract/twakePlugin'; import { type Role } from '../../abstract/plugin'; import type { DM } from '../../bin'; import type { AttributeValue } from '../../lib/ldapActions'; import { Hooks } from '../../hooks'; export default class Drive extends TwakePlugin { name = 'drive'; roles: Role[] = ['consistency'] as const; dependencies = { onLdapChange: 'core/ldap/onChange', }; // Drive-specific configuration attributes private cozyDomainAttr: string; private defaultDomainTemplate: string; private templateAttrs: string[]; private driveQuotaAttr: string; constructor(server: DM) { super( server, 'twake_drive_webadmin_url', 'twake_drive_webadmin_token', 'twake_drive_concurrency' ); // LDAP attribute that stores the Cozy instance domain (e.g., "john.mycozy.cloud") this.cozyDomainAttr = (this.config.twake_drive_domain_attribute as string) || 'twakeCozyDomain'; // Default domain template for fallback (e.g., "{uid}.company.cloud") this.defaultDomainTemplate = (this.config.twake_drive_default_domain_template as string) || ''; // Extract attribute names from template placeholders (e.g., "{uid}" → "uid") this.templateAttrs = this.defaultDomainTemplate ? [...this.defaultDomainTemplate.matchAll(/\{(\w+)\}/g)].map(m => m[1]) : []; // LDAP attribute that stores the drive quota in bytes this.driveQuotaAttr = (this.config.drive_quota_attribute as string) || 'twakeDriveQuota'; } /** * Override createHeaders to use Basic Auth for Cozy Admin API * Cozy uses Basic Auth with empty username and password as the token * @param contentType Optional Content-Type header value * @returns Headers object with Basic Auth */ protected override createHeaders(contentType?: string): { Authorization?: string; 'Content-Type'?: string; } { const headers: { Authorization?: string; 'Content-Type'?: string } = {}; if (this.webadminToken) { // Cozy Admin API uses Basic Auth with empty username const basicAuth = Buffer.from(`:${this.webadminToken}`).toString( 'base64' ); headers.Authorization = `Basic ${basicAuth}`; } if (contentType) { headers['Content-Type'] = contentType; } return headers; } /** * Validate Cozy domain format to prevent URL injection attacks * @param domain Cozy domain to validate * @returns true if domain format is valid */ private isValidCozyDomain(domain: string): boolean { // Allow only valid domain characters (alphanumeric, dots, hyphens) // Reject any path separators, query strings, or fragments // Domain must start and end with alphanumeric, and be between 1-253 chars if (!domain || domain.length > 253) return false; if ( domain.includes('/') || domain.includes('\\') || domain.includes('?') || domain.includes('#') ) { return false; } // Valid domain pattern: one or more labels separated by dots // Each label must start and end with alphanumeric, may contain hyphens in the middle // Single-label domains (e.g., "localhost", "cozy-server") are allowed for local/search domain resolution const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; return domainRegex.test(domain); } /** * Get Cozy domain for a user from LDAP * Falls back to template-based domain if attribute not found * @param dn User's LDAP DN * @returns Cozy domain or null if not found */ async getCozyDomain(dn: string): Promise { // Fetch cozy domain attribute + any attributes needed for template const attrsToFetch = [this.cozyDomainAttr, ...this.templateAttrs]; const entry = await this.ldapGetAttributes(dn, attrsToFetch); // First try the explicit Cozy domain attribute const explicitDomain = entry ? this.attributeToString(entry[this.cozyDomainAttr]) : null; if (explicitDomain) { return explicitDomain; } // Fallback: use template if configured if (this.defaultDomainTemplate && entry) { let domain = this.defaultDomainTemplate; for (const attr of this.templateAttrs) { const value = this.attributeToString(entry[attr]); if (!value) { // Missing attribute, can't build domain from template return null; } domain = domain.replace(`{${attr}}`, value); } return domain; } return null; } /** * Update Cozy instance via Admin API * @param hookname Hook name for logging * @param cozyDomain Cozy instance domain * @param dn User's LDAP DN * @param params Query parameters to update */ private async updateCozyInstance( hookname: string, cozyDomain: string, dn: string, params: Record ): Promise { // Validate cozyDomain to prevent URL injection attacks if (!this.isValidCozyDomain(cozyDomain)) { this.logger.error({ plugin: this.name, event: hookname, result: 'error', dn, cozyDomain, error: 'Invalid Cozy domain format (potential URL injection attempt)', }); return; } // Build URL with query parameters const url = new URL(`${this.webadminUrl}/instances/${cozyDomain}`); // Always add FromCloudery=true to prevent callback loops url.searchParams.set('FromCloudery', 'true'); // Add update parameters for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); } return this.requestLimit(async () => { const log = { plugin: this.name, event: hookname, result: 'error', dn, cozyDomain, ...params, }; try { const res = await fetch(url.toString(), { method: 'PATCH', headers: this.createHeaders('application/json'), }); if (!res.ok) { // 404 is acceptable - instance may not exist yet if (res.status === 404) { this.logger.debug({ ...log, result: 'ignored', http_status: res.status, http_status_text: res.statusText, message: 'Cozy instance not found (may not be provisioned yet)', }); } else { this.logger.error({ ...log, http_status: res.status, http_status_text: res.statusText, url: url.toString(), }); } } else { this.logger.info({ ...log, result: 'success', http_status: res.status, }); } } catch (err) { this.logger.error({ ...log, error: err, url: url.toString(), }); } }); } hooks: Hooks = { /** * Handle email address changes * Updates the Cozy instance's Email parameter */ onLdapMailChange: async ( dn: string, oldmail: AttributeValue | null, newmail: AttributeValue | null ) => { // Convert to strings const oldmailStr = oldmail ? Array.isArray(oldmail) ? String(oldmail[0]) : String(oldmail) : null; const newmailStr = newmail ? Array.isArray(newmail) ? String(newmail[0]) : String(newmail) : null; // Skip if oldmail is empty/null (this is an add, not a change) if (!oldmailStr) { this.logger.debug( `Skipping mail change for ${dn}: oldmail is empty (mail attribute was added, not changed)` ); return; } // Skip if newmail is empty if (!newmailStr) { this.logger.debug( `Skipping mail change for ${dn}: newmail is empty (mail attribute was deleted)` ); return; } // Get Cozy domain for this user const cozyDomain = await this.getCozyDomain(dn); if (!cozyDomain) { this.logger.debug( `Skipping mail change for ${dn}: no Cozy domain attribute (${this.cozyDomainAttr})` ); return; } // Update Cozy instance email await this.updateCozyInstance('onLdapMailChange', cozyDomain, dn, { Email: newmailStr, }); }, /** * Handle display name changes * Updates the Cozy instance's PublicName parameter */ onLdapDisplayNameChange: async ( dn: string, _oldDisplayName: string | null, _newDisplayName: string | null ) => { // Get Cozy domain and display name const entry = await this.ldapGetAttributes(dn, [ this.cozyDomainAttr, this.displayNameAttr, 'cn', 'givenName', 'sn', ]); if (!entry) { this.logger.warn( `Cannot update Cozy display name: entry not found for ${dn}` ); return; } const cozyDomain = this.attributeToString(entry[this.cozyDomainAttr]); if (!cozyDomain) { this.logger.debug( `Skipping display name change for ${dn}: no Cozy domain attribute (${this.cozyDomainAttr})` ); return; } // Get display name with fallback logic const displayName = this.getDisplayNameFromAttributes(entry); if (!displayName) { this.logger.warn( `Cannot update Cozy display name: no display name found for ${dn}` ); return; } // Update Cozy instance public name await this.updateCozyInstance('onLdapDisplayNameChange', cozyDomain, dn, { PublicName: displayName, }); }, /** * Handle drive quota changes * Updates the Twake Drive instance's DiskQuota parameter */ onLdapDriveQuotaChange: async ( dn: string, oldDriveQuota: number | null, newDriveQuota: number | null ) => { // Skip if newQuota is null (quota was deleted) if (newDriveQuota === null) { this.logger.debug( `Skipping drive quota change for ${dn}: quota was deleted` ); return; } // Get Twake Drive domain for this user const cozyDomain = await this.getCozyDomain(dn); if (!cozyDomain) { this.logger.debug( `Skipping drive quota change for ${dn}: no Twake Drive domain attribute (${this.cozyDomainAttr})` ); return; } // Update Twake Drive instance disk quota (in bytes) await this.updateCozyInstance('onLdapDriveQuotaChange', cozyDomain, dn, { DiskQuota: String(newDriveQuota), }); }, }; /** * Extract display name from LDAP attributes * Fallback logic: displayName → cn → givenName+sn */ private getDisplayNameFromAttributes( attributes: import('../../lib/ldapActions').AttributesList ): string | null { // 1. Try displayName first const displayName = this.attributeToString( attributes[this.displayNameAttr] ); if (displayName) return displayName; // 2. Try cn const cn = this.attributeToString(attributes.cn); if (cn) return cn; // 3. Try givenName + sn const givenName = this.attributeToString(attributes.givenName); const sn = this.attributeToString(attributes.sn); if (givenName || sn) { const parts = []; if (givenName) parts.push(givenName); if (sn) parts.push(sn); return parts.join(' '); } return null; } /** * Public method to get display name from DN * Useful for external integrations */ async getDisplayNameFromDN(dn: string): Promise { const attrs = [this.displayNameAttr, 'cn', 'givenName', 'sn']; const entry = await this.ldapGetAttributes(dn, attrs); return entry ? this.getDisplayNameFromAttributes(entry) : null; } /** * Public method to get mail from DN * Useful for external integrations */ async getMailFromDN(dn: string): Promise { const entry = await this.ldapGetAttributes(dn, [this.mailAttr]); return entry ? this.attributeToString(entry[this.mailAttr]) : null; } /** * Public method to get drive quota from DN (in bytes) * Useful for external integrations */ async getDriveQuotaFromDN(dn: string): Promise { const entry = await this.ldapGetAttributes(dn, [this.driveQuotaAttr]); if (!entry) return null; const quota = this.attributeToString(entry[this.driveQuotaAttr]); return quota ? Number(quota) : null; } /** * Public method to manually sync a user to Twake Drive * Useful for initial sync or manual refresh */ async syncUserToCozy(dn: string): Promise { const entry = await this.ldapGetAttributes(dn, [ this.cozyDomainAttr, this.mailAttr, this.displayNameAttr, this.driveQuotaAttr, 'cn', 'givenName', 'sn', ]); if (!entry) { this.logger.error(`Cannot sync user: entry not found for ${dn}`); return false; } const cozyDomain = this.attributeToString(entry[this.cozyDomainAttr]); if (!cozyDomain) { this.logger.error( `Cannot sync user: no Twake Drive domain attribute for ${dn}` ); return false; } const mail = this.attributeToString(entry[this.mailAttr]); const displayName = this.getDisplayNameFromAttributes(entry); const driveQuota = this.attributeToString(entry[this.driveQuotaAttr]); const params: Record = {}; if (mail) params.Email = mail; if (displayName) params.PublicName = displayName; if (driveQuota) params.DiskQuota = driveQuota; if (Object.keys(params).length === 0) { this.logger.warn(`No attributes to sync for ${dn}`); return false; } await this.updateCozyInstance('syncUserToCozy', cozyDomain, dn, params); return true; } /** * Block a Cozy instance * @param dn User's LDAP DN * @param reason Optional blocking reason (e.g., 'PAYMENT_FAILED', 'LOGIN_FAILED', 'SUSPENDED') * @returns true if successful, false otherwise */ async blockInstance(dn: string, reason?: string): Promise { const cozyDomain = await this.getCozyDomain(dn); if (!cozyDomain) { this.logger.error(`Cannot block instance: no Cozy domain for ${dn}`); return false; } const params: Record = { Blocked: 'true' }; if (reason) { params.BlockingReason = reason; } await this.updateCozyInstance('blockInstance', cozyDomain, dn, params); return true; } /** * Unblock a Cozy instance * @param dn User's LDAP DN * @returns true if successful, false otherwise */ async unblockInstance(dn: string): Promise { const cozyDomain = await this.getCozyDomain(dn); if (!cozyDomain) { this.logger.error(`Cannot unblock instance: no Cozy domain for ${dn}`); return false; } await this.updateCozyInstance('unblockInstance', cozyDomain, dn, { Blocked: 'false', }); return true; } /** * Delete a Cozy instance and all its data * Calls DELETE /instances/:domain on the Cozy Admin API * @param dn User's LDAP DN * @returns true if successful, false otherwise */ async deleteInstance(dn: string): Promise { const cozyDomain = await this.getCozyDomain(dn); if (!cozyDomain) { this.logger.error(`Cannot delete instance: no Cozy domain for ${dn}`); return false; } if (!this.isValidCozyDomain(cozyDomain)) { this.logger.error({ plugin: this.name, event: 'deleteInstance', result: 'error', dn, cozyDomain, error: 'Invalid Cozy domain format (potential URL injection attempt)', }); return false; } const url = `${this.webadminUrl}/instances/${cozyDomain}`; return this.requestLimit(async () => { try { const res = await fetch(url, { method: 'DELETE', headers: this.createHeaders(), }); if (!res.ok) { if (res.status === 404) { this.logger.info({ plugin: this.name, event: 'deleteInstance', result: 'ignored', dn, cozyDomain, http_status: 404, message: 'Cozy instance not found (already deleted or never created)', }); return true; } this.logger.error({ plugin: this.name, event: 'deleteInstance', result: 'error', dn, cozyDomain, http_status: res.status, http_status_text: res.statusText, url, }); return false; } this.logger.info({ plugin: this.name, event: 'deleteInstance', result: 'success', dn, cozyDomain, http_status: res.status, }); return true; } catch (err) { this.logger.error({ plugin: this.name, event: 'deleteInstance', result: 'error', dn, cozyDomain, error: err, url, }); return false; } }); } } linagora-ldap-rest-16e557e/src/plugins/twake/james.ts000066400000000000000000001301371522642357000226070ustar00rootroot00000000000000import fetch from 'node-fetch'; import type { Express, Request, Response } from 'express'; import TwakePlugin from '../../abstract/twakePlugin'; import { type Role, asyncHandler } from '../../abstract/plugin'; import type { DM } from '../../bin'; import type { AttributesList, AttributeValue, SearchResult, } from '../../lib/ldapActions'; import { Hooks } from '../../hooks'; import type { ChangesToNotify } from '../ldap/onChange'; import { wantJson } from '../../lib/expressFormatedResponses'; import { escapeLdapFilter } from '../../lib/utils'; /** * OpenAPI schemas specific to the James integration. * * @openapi-component * JamesQuotaUsage: * type: object * description: | * Quota information for a mailbox user as returned by the James * WebAdmin API (`GET /quota/users/{mail}`). Contains separate * counters for message count and storage size. * properties: * count: * type: object * description: Message-count quota. * properties: * used: * type: integer * description: Number of messages currently stored. * example: 142 * max: * type: integer * nullable: true * description: | * Maximum number of messages allowed. `null` means unlimited. * example: 10000 * size: * type: object * description: Storage-size quota (bytes). * properties: * used: * type: integer * description: Bytes currently used. * example: 524288000 * max: * type: integer * nullable: true * description: | * Maximum bytes allowed. `null` means unlimited. * example: 2147483648 * example: * count: * used: 142 * max: 10000 * size: * used: 524288000 * max: 2147483648 */ export default class James extends TwakePlugin { name = 'james'; roles: Role[] = ['consistency', 'api'] as const; dependencies = { onLdapChange: 'core/ldap/onChange', ldapGroups: 'core/ldap/groups', }; // James-specific configuration attributes private quotaAttr: string; private aliasAttr: string; private mailboxTypeAttr: string; private initDelay: number; constructor(server: DM) { super( server, 'james_webadmin_url', 'james_webadmin_token', 'james_concurrency' ); // Initialize James-specific configuration attributes this.quotaAttr = (this.config.quota_attribute as string) || 'mailQuotaSize'; this.aliasAttr = (this.config.alias_attribute as string) || 'mailAlternateAddress'; this.mailboxTypeAttr = (this.config.james_mailbox_type_attribute as string) || 'twakeMailboxType'; this.initDelay = typeof this.config.james_init_delay === 'number' ? this.config.james_init_delay : 1000; } /** * Register API routes */ api(app: Express): void { const apiPrefix = this.config.api_prefix || '/api'; /** * @openapi * summary: Get user quota usage * description: | * Returns the current mailbox quota counters (message count and * storage size) for the given user. Requires the Apache James * integration to be configured (`james_webadmin_url`, * `james_webadmin_token`). The quota data is fetched live from * the James WebAdmin API. * tags: * - Apache James Integration * responses: * '200': * description: Quota information retrieved successfully. * content: * application/json: * schema: { $ref: '#/components/schemas/JamesQuotaUsage' } * example: * count: * used: 142 * max: 10000 * size: * used: 524288000 * max: 2147483648 * '400': * description: User has no mail attribute configured in LDAP. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } * example: * error: User alice has no mail attribute * code: 400 * '404': * description: User not found in LDAP, or quota entry absent in James. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } * example: * error: User alice not found * code: 404 * '502': * description: James WebAdmin API is unreachable or returned an error. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } * example: * error: "Failed to retrieve quota from James: Bad Gateway" * code: 502 * '500': * description: Unexpected internal error. * content: * application/json: * schema: { $ref: '#/components/schemas/Error' } */ // Get quota usage and limits for a user app.get( `${apiPrefix}/v1/users/:user/quota-usage`, asyncHandler((req: Request, res: Response) => this.getUserQuota(req, res)) ); this.logger.info( `James API registered at ${apiPrefix}/v1/users/:user/quota-usage` ); } /** * Get quota information for a user */ private async getUserQuota(req: Request, res: Response): Promise { if (!wantJson(req, res)) return; const username = req.params.user as string; try { // Get user's mail from LDAP (escape username to prevent LDAP injection) const escapedUsername = escapeLdapFilter(username); const userResult = await this.server.ldap.search( { scope: 'sub', filter: `(uid=${escapedUsername})`, paged: false, attributes: [this.mailAttr], }, this.config.ldap_base || '' ); const userEntry = (userResult as SearchResult).searchEntries?.[0]; if (!userEntry) { res.status(404).json({ error: `User ${username} not found` }); return; } const mail = userEntry[this.mailAttr]; if (!mail) { res .status(400) .json({ error: `User ${username} has no mail attribute` }); return; } const mailStr = Array.isArray(mail) ? String(mail[0]) : String(mail); // Get quota from James WebAdmin API const quotaUrl = `${this.webadminUrl}/quota/users/${mailStr}`; const quotaRes = await this.requestLimit(() => fetch(quotaUrl, { method: 'GET', headers: this.createHeaders(), }) ); if (!quotaRes.ok) { if (quotaRes.status === 404) { res .status(404) .json({ error: `Quota not found for user ${username}` }); } else { res.status(502).json({ error: `Failed to retrieve quota from James: ${quotaRes.statusText}`, }); } return; } const quotaData = await quotaRes.json(); res.json(quotaData); } catch (error) { this.logger.error({ plugin: this.name, event: 'getUserQuota', username, error: error instanceof Error ? error.message : String(error), }); res.status(500).json({ error: 'Internal server error while retrieving quota', }); } } hooks: Hooks = { ldapadddone: async (args: [string, AttributesList]) => { const [dn, attributes] = args; const mail = attributes[this.mailAttr]; const quota = attributes[this.quotaAttr]; const aliases = attributes[this.aliasAttr]; if (!mail) { // Not a user with mail, skip return; } const mailStr = Array.isArray(mail) ? String(mail[0]) : String(mail); // Wait for James to create the user (configurable delay) if (this.initDelay > 0) { // eslint-disable-next-line no-undef -- setTimeout is a Node.js global await new Promise(resolve => setTimeout(resolve, this.initDelay)); } // Initialize quota if present if (quota) { const quotaNum = Array.isArray(quota) ? Number(quota[0]) : Number(quota); if (!isNaN(quotaNum) && quotaNum > 0) { await this.callWebAdminApi( 'ldapadddone:quota', `${this.webadminUrl}/quota/users/${mailStr}/size`, 'PUT', dn, quotaNum.toString(), { mail: mailStr, quota: quotaNum } ); } } // Create aliases if present (parallelize API calls) if (aliases) { const aliasList = this.getAliases(aliases); await Promise.all( aliasList.map(alias => this.callWebAdminApi( 'ldapadddone:alias', `${this.webadminUrl}/address/aliases/${mailStr}/sources/${alias}`, 'PUT', dn, null, { mail: mailStr, alias } ) ) ); } // Initialize James identity const displayName = this.getDisplayNameFromAttributes(attributes); if (displayName) { await this.updateJamesIdentity(dn, mailStr, displayName); } }, onLdapMailChange: async ( dn: string, oldmail: AttributeValue | null, newmail: AttributeValue | null ) => { // Convert to strings const oldmailStr = oldmail ? Array.isArray(oldmail) ? String(oldmail[0]) : String(oldmail) : null; const newmailStr = newmail ? Array.isArray(newmail) ? String(newmail[0]) : String(newmail) : null; // Skip if oldmail is empty/null (this is an add, not a change) // The mailbox will be created by ldapadddone if (!oldmailStr) { this.logger.debug( `Skipping mail rename for ${dn}: oldmail is empty (mail attribute was added, not changed)` ); return; } // Skip if newmail is empty/null (this is a deletion, not a rename). // Without this guard the URL would target `/rename/null` and James could // rename the mailbox to a literal "null" address. if (!newmailStr) { this.logger.debug( `Skipping mail rename for ${dn}: newmail is empty (mail attribute was deleted, not changed)` ); return; } // Rename the mailbox await this.callWebAdminApi( 'onLdapMailChange', `${this.webadminUrl}/users/${oldmailStr}/rename/${newmailStr}?action=rename&force`, 'POST', dn, null, { oldmail: oldmailStr, newmail: newmailStr } ); // Get current aliases from LDAP and recreate them for the new mail try { const entry = (await this.server.ldap.search( { paged: false, scope: 'base', attributes: [this.aliasAttr] }, dn )) as SearchResult; if (entry.searchEntries && entry.searchEntries.length > 0) { const aliases = this.getAliases( entry.searchEntries[0][this.aliasAttr] ); // Only process if user has aliases if (aliases.length > 0) { // Delete old aliases and create new ones in parallel await Promise.all([ ...aliases.map(alias => this.callWebAdminApi( 'onLdapMailChange-delete', `${this.webadminUrl}/address/aliases/${oldmailStr}/sources/${alias}`, 'DELETE', dn, null, { oldmail: oldmailStr, alias } ) ), ...aliases.map(alias => this.callWebAdminApi( 'onLdapMailChange-create', `${this.webadminUrl}/address/aliases/${newmailStr}/sources/${alias}`, 'PUT', dn, null, { newmail: newmailStr, alias } ) ), ]); } } } catch (err) { // Silently ignore if user has no aliases attribute this.logger.debug('Could not fetch aliases for mail change:', err); } }, onLdapAliasChange: async ( dn: string, mail: string, oldAliases: string[], newAliases: string[] ) => { // Normalize aliases const oldNormalized = oldAliases.map(a => this.normalizeAlias(a)); const newNormalized = newAliases.map(a => this.normalizeAlias(a)); // Find aliases to delete (in old but not in new) const toDelete = oldNormalized.filter(a => !newNormalized.includes(a)); // Find aliases to add (in new but not in old) const toAdd = newNormalized.filter(a => !oldNormalized.includes(a)); // Delete and add aliases in parallel await Promise.all([ ...toDelete.map(alias => this.callWebAdminApi( 'onLdapAliasChange-delete', `${this.webadminUrl}/address/aliases/${mail}/sources/${alias}`, 'DELETE', dn, null, { mail, alias, action: 'delete' } ) ), ...toAdd.map(alias => this.callWebAdminApi( 'onLdapAliasChange-add', `${this.webadminUrl}/address/aliases/${mail}/sources/${alias}`, 'PUT', dn, null, { mail, alias, action: 'add' } ) ), ]); }, onLdapQuotaChange: ( dn: string, mail: string, oldQuota: number, newQuota: number ) => { return this.callWebAdminApi( 'onLdapQuotaChange', `${this.webadminUrl}/quota/users/${mail}/size`, 'PUT', dn, newQuota.toString(), { oldQuota, newQuota } ); }, onLdapForwardChange: async ( dn: string, mail: string, oldForwards: string[], newForwards: string[] ) => { const domain = this.extractMailDomain(mail); if (!domain) { this.logger.error( `Cannot extract domain from mail ${mail} for forward management` ); return; } // Find forwards to delete (in old but not in new) const toDelete = oldForwards.filter(f => !newForwards.includes(f)); // Find forwards to add (in new but not in old) const toAdd = newForwards.filter(f => !oldForwards.includes(f)); // Delete and add forwards in parallel await Promise.all([ ...toDelete.map(forward => this.callWebAdminApi( 'onLdapForwardChange-delete', `${this.webadminUrl}/address/forwards/${mail}/targets/${forward}`, 'DELETE', dn, null, { mail, forward, domain, action: 'delete' } ) ), ...toAdd.map(forward => this.callWebAdminApi( 'onLdapForwardChange-add', `${this.webadminUrl}/address/forwards/${mail}/targets/${forward}`, 'PUT', dn, null, { mail, forward, domain, action: 'add' } ) ), ]); }, onLdapChange: async (dn: string, changes: ChangesToNotify) => { if ( this.config.delegation_attribute && changes[this.config.delegation_attribute] ) { await this._handleDelegationChange(dn, changes); } }, onLdapDisplayNameChange: async ( dn: string // oldDisplayName: string | null, // newDisplayName: string | null ) => { // Get mail and display name attributes using cached ldapGetAttributes const attrs = [ this.mailAttr, this.displayNameAttr, 'cn', 'givenName', 'sn', ]; const entry = await this.ldapGetAttributes(dn, attrs); if (!entry) { this.logger.warn( `Cannot update James identity: entry not found for ${dn}` ); return; } const mail = this.attributeToString(entry[this.mailAttr]); if (!mail) { this.logger.warn( `Cannot update James identity: no mail found for ${dn}` ); return; } const displayName = this.getDisplayNameFromAttributes(entry); if (!displayName) { this.logger.warn( `Cannot update James identity: no display name found for ${dn}` ); return; } // Update James identity via JMAP return this.updateJamesIdentity(dn, mail, displayName); }, // Group/mailing list hooks ldapgroupadddone: async (args: [string, AttributesList]) => { const [dn, attributes] = args; const mail = attributes.mail as string | string[] | undefined; // Only handle groups with a mail attribute if (!mail) { this.logger.debug( `Group ${dn} has no mail attribute, skipping James sync` ); return; } const mailboxType = this.getMailboxType(attributes); const mailStr = Array.isArray(mail) ? String(mail[0]) : String(mail); // Dispatch based on mailbox type if (mailboxType === 'teamMailbox') { // Validate team mailbox is NOT in mailing list branches const branches = this.config.james_mailing_list_branch || []; if (branches.length > 0 && this.isInAllowedBranches(dn, branches)) { this.logger.error({ plugin: this.name, event: 'ldapgroupadddone', dn, mailboxType: 'teamMailbox', error: `Team mailbox cannot be in mailing list branches: ${branches.join(', ')}`, }); return; } await this.createTeamMailbox(dn, attributes, mailStr); } else if (mailboxType === 'mailingList' || mailboxType === null) { // Validate mailing list is in allowed branches const branches = this.config.james_mailing_list_branch || []; if (branches.length > 0 && !this.isInAllowedBranches(dn, branches)) { this.logger.error({ plugin: this.name, event: 'ldapgroupadddone', dn, mailboxType: mailboxType || 'mailingList (default)', error: `Mailing list must be in allowed branches: ${branches.join(', ')}`, }); return; } await this.createMailingList(dn, attributes, mailStr); } // If mailboxType === 'group', do nothing (simple group without mailbox) }, ldapgroupmodifydone: async ( args: [ string, { add?: AttributesList; replace?: AttributesList; delete?: string[] | AttributesList; }, number, ] ) => { const [dn, changes] = args; // Check if mailboxType is being changed if ( changes.replace?.[this.mailboxTypeAttr] || changes.add?.[this.mailboxTypeAttr] ) { await this.handleMailboxTypeTransition(dn, changes); return; // Transition handles everything including members } // Get the group's mail address and mailbox type const groupMail = await this.getGroupMail(dn); if (!groupMail) { this.logger.debug( `Group ${dn} has no mail attribute, skipping James sync` ); return; } const mailboxType = await this.getGroupMailboxType(dn); const isTeamMailbox = mailboxType === 'teamMailbox'; // Determine the API endpoint based on mailbox type const domain = isTeamMailbox ? this.extractMailDomain(groupMail) : null; // Handle member additions and deletions in parallel const operations: Promise[] = []; if (changes.add?.member) { const addMember = changes.add.member as string | string[]; const membersToAdd = Array.isArray(addMember) ? addMember : [addMember]; const memberMails = await this.getMemberEmails(membersToAdd); operations.push( ...memberMails.map(memberMail => { if (isTeamMailbox && domain) { return this.callWebAdminApi( 'ldapgroupmodifydone:teamMailbox', `${this.webadminUrl}/domains/${domain}/team-mailboxes/${groupMail}/members/${memberMail}`, 'PUT', dn, null, { domain, groupMail, memberMail, action: 'add' } ); } else { return this.callWebAdminApi( 'ldapgroupmodifydone:mailingList', `${this.webadminUrl}/address/groups/${groupMail}/${memberMail}`, 'PUT', dn, null, { groupMail, memberMail, action: 'add' } ); } }) ); } if (this.isAttributesList(changes.delete)) { const deleteMember = changes.delete.member as | string | string[] | undefined; if (deleteMember) { const membersToDelete = Array.isArray(deleteMember) ? deleteMember : [deleteMember]; const memberMails = await this.getMemberEmails(membersToDelete); operations.push( ...memberMails.map(memberMail => { if (isTeamMailbox && domain) { return this.callWebAdminApi( 'ldapgroupmodifydone:teamMailbox', `${this.webadminUrl}/domains/${domain}/team-mailboxes/${groupMail}/members/${memberMail}`, 'DELETE', dn, null, { domain, groupMail, memberMail, action: 'delete' } ); } else { return this.callWebAdminApi( 'ldapgroupmodifydone:mailingList', `${this.webadminUrl}/address/groups/${groupMail}/${memberMail}`, 'DELETE', dn, null, { groupMail, memberMail, action: 'delete' } ); } }) ); } } await Promise.all(operations); }, ldapgroupdeletedone: async (dn: string) => { // Get the group's mail address and mailbox type before deletion const groupMail = await this.getGroupMail(dn); if (!groupMail) { this.logger.debug( `Group ${dn} has no mail attribute, skipping James sync` ); return; } const mailboxType = await this.getGroupMailboxType(dn); if (mailboxType === 'teamMailbox') { // For team mailboxes, remove all members but preserve the mailbox await this.removeAllTeamMailboxMembers(dn, groupMail); } else { // Delete the entire address group from James (mailing list) await this.callWebAdminApi( 'ldapgroupdeletedone:mailingList', `${this.webadminUrl}/address/groups/${groupMail}`, 'DELETE', dn, null, { groupMail } ); } }, }; /** * Handle mailbox type transitions when twakeMailboxType changes */ private async handleMailboxTypeTransition( dn: string, changes: { add?: AttributesList; replace?: AttributesList; delete?: string[] | AttributesList; } ): Promise { // Get current group attributes const attributes = await this.ldapGetAttributes(dn, [ 'mail', 'member', this.mailboxTypeAttr, ]); if (!attributes) { this.logger.error({ plugin: this.name, event: 'handleMailboxTypeTransition', dn, error: 'Could not fetch group attributes', }); return; } const mail = attributes.mail; if (!mail) { this.logger.debug( `Group ${dn} has no mail attribute, skipping mailbox type transition` ); return; } const mailStr = Array.isArray(mail) ? String(mail[0]) : String(mail); // Determine old and new mailbox types const newMailboxTypeDn = changes.replace?.[this.mailboxTypeAttr] || changes.add?.[this.mailboxTypeAttr]; const newMailboxTypeStr = Array.isArray(newMailboxTypeDn) ? String(newMailboxTypeDn[0]) : String(newMailboxTypeDn); const newType = this.getMailboxType({ [this.mailboxTypeAttr]: newMailboxTypeStr, }); const oldType = this.getMailboxType(attributes); this.logger.info({ plugin: this.name, event: 'handleMailboxTypeTransition', dn, mail: mailStr, oldType: oldType || 'none', newType: newType || 'none', }); // Handle transition based on old and new types if (oldType === newType) { // No actual change return; } // Cleanup old mailbox type if (oldType === 'mailingList') { await this.deleteMailingList(dn, mailStr); } else if (oldType === 'teamMailbox') { // Remove all members but preserve the mailbox await this.removeAllTeamMailboxMembers(dn, mailStr); } // Setup new mailbox type with validation if (newType === 'mailingList') { // Validate mailing list is in allowed branches const branches = this.config.james_mailing_list_branch || []; if (branches.length > 0 && !this.isInAllowedBranches(dn, branches)) { this.logger.error({ plugin: this.name, event: 'handleMailboxTypeTransition', dn, newType, error: `Mailing list must be in allowed branches: ${branches.join(', ')}`, }); return; } await this.createMailingList(dn, attributes, mailStr); } else if (newType === 'teamMailbox') { // Validate team mailbox is NOT in mailing list branches const branches = this.config.james_mailing_list_branch || []; if (branches.length > 0 && this.isInAllowedBranches(dn, branches)) { this.logger.error({ plugin: this.name, event: 'handleMailboxTypeTransition', dn, newType, error: `Team mailbox cannot be in mailing list branches: ${branches.join(', ')}`, }); return; } await this.createTeamMailbox(dn, attributes, mailStr); } // If newType === 'group', nothing to create (simple group) } /** * Extract display name from LDAP attributes * Fallback logic: displayName → cn → givenName+sn → mail */ private getDisplayNameFromAttributes( attributes: import('../../lib/ldapActions').AttributesList ): string | null { // 1. Try displayName first const displayName = this.attributeToString( attributes[this.displayNameAttr] ); if (displayName) return displayName; // 2. Try cn const cn = this.attributeToString(attributes.cn); if (cn) return cn; // 3. Try givenName + sn const givenName = this.attributeToString(attributes.givenName); const sn = this.attributeToString(attributes.sn); if (givenName || sn) { const parts = []; if (givenName) parts.push(givenName); if (sn) parts.push(sn); return parts.join(' '); } // 4. Fallback to mail const mail = this.attributeToString(attributes[this.mailAttr]); if (mail) return mail; return null; } async getMailFromDN(dn: string): Promise { const entry = await this.ldapGetAttributes(dn, [this.mailAttr]); return entry ? this.attributeToString(entry[this.mailAttr]) : null; } async getDisplayNameFromDN(dn: string): Promise { const attrs = [ this.displayNameAttr, 'cn', 'givenName', 'sn', this.mailAttr, ]; const entry = await this.ldapGetAttributes(dn, attrs); return entry ? this.getDisplayNameFromAttributes(entry) : null; } async generateSignature(dn: string): Promise { const template = this.config.james_signature_template; if (!template) return null; // Get all attributes (needed for template placeholders) const entry = await this.ldapGetAttributes(dn); if (!entry) return null; // Replace all {attributeName} placeholders with LDAP values let signature = template; const placeholderRegex = /\{(\w+)\}/g; signature = signature.replace( placeholderRegex, (_match: string, attrName: string): string => { if ( typeof attrName === 'string' && entry && typeof entry === 'object' && Object.prototype.hasOwnProperty.call(entry, attrName) ) { return ( this.attributeToString( (entry as Record)[attrName] ) || '' ); } return ''; } ); return signature; } async updateJamesIdentity( dn: string, mail: string, displayName: string ): Promise { const log = { plugin: this.name, event: 'onLdapDisplayNameChange', result: 'error', dn, mail, displayName, }; try { // Step 1: Get user identities const identitiesUrl = `${this.webadminUrl}/users/${mail}/identities`; const getRes = await this.requestLimit(() => fetch(identitiesUrl, { method: 'GET', headers: this.createHeaders(), }) ); if (!getRes.ok) { this.logger.error({ ...log, step: 'get_identities', http_status: getRes.status, http_status_text: getRes.statusText, }); return; } const identities = (await getRes.json()) as Array<{ id: string; name: string; email: string; }>; // Step 2: Find default identity (first one or the one matching the email) const defaultIdentity = identities.find(id => id.email === mail) || identities[0]; if (!defaultIdentity) { this.logger.warn({ ...log, step: 'find_identity', message: 'No identity found for user', }); return; } // Step 3: Generate signature if template is configured const htmlSignature = await this.generateSignature(dn); // Step 4: Update identity name and signature const updateUrl = `${this.webadminUrl}/users/${mail}/identities/${defaultIdentity.id}`; const updatePayload: { id: string; email: string; name: string; htmlSignature?: string; } = { id: defaultIdentity.id, email: defaultIdentity.email, name: displayName, }; if (htmlSignature) { updatePayload.htmlSignature = htmlSignature; } const updateRes = await this.requestLimit(() => fetch(updateUrl, { method: 'PUT', headers: this.createHeaders('application/json'), body: JSON.stringify(updatePayload), }) ); if (!updateRes.ok) { this.logger.error({ ...log, step: 'update_identity', http_status: updateRes.status, http_status_text: updateRes.statusText, }); } else { this.logger.info({ ...log, result: 'success', http_status: updateRes.status, }); } } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions this.logger.error({ ...log, error: `${err}` }); } } /** * Delete user data via James WebAdmin API * Calls POST /users/{mail}?action=deleteData * Returns task information from the response */ async deleteUserData( mail: string, fromStep?: string ): Promise<{ taskId: string } | null> { const log = { plugin: this.name, event: 'deleteUserData', mail, fromStep, }; try { const url = new URL(`${this.webadminUrl}/users/${mail}`); url.searchParams.set('action', 'deleteData'); if (fromStep) { url.searchParams.set('fromStep', fromStep); } const response = await this.requestLimit(() => fetch(url.toString(), { method: 'POST', headers: this.createHeaders(), }) ); if (!response.ok) { this.logger.error({ ...log, http_status: response.status, http_status_text: response.statusText, }); return null; } const taskInfo = (await response.json()) as { taskId: string }; this.logger.info({ ...log, http_status: response.status, taskId: taskInfo.taskId, }); return taskInfo; } catch (err) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions this.logger.error({ ...log, error: `${err}` }); return null; } } /** * Type guard to check if delete operation is an AttributesList */ private isAttributesList( value: string[] | AttributesList | undefined ): value is AttributesList { return ( value !== undefined && typeof value === 'object' && !Array.isArray(value) ); } /** * Extract mailbox type from group attributes * Returns: 'group', 'mailingList', 'teamMailbox', or null if not set */ private getMailboxType( attributes: AttributesList ): 'group' | 'mailingList' | 'teamMailbox' | null { const mailboxType = attributes[this.mailboxTypeAttr]; if (!mailboxType) return null; // Extract cn from DN (e.g., "cn=mailingList,ou=twakeMailboxType,...") const mailboxTypeDn = Array.isArray(mailboxType) ? String(mailboxType[0]) : String(mailboxType); const match = mailboxTypeDn.match(/^cn=([^,]+)/); if (!match) return null; const type = match[1]; if (type === 'group' || type === 'mailingList' || type === 'teamMailbox') { return type; } return null; } /** * Validate that a DN is within one of the allowed branches */ private isInAllowedBranches(dn: string, branches: string[]): boolean { if (!branches || branches.length === 0) return true; // No restriction return branches.some(branch => dn === branch || dn.endsWith(',' + branch)); } /** * Create a mailing list in James */ private async createMailingList( dn: string, attributes: AttributesList, mailStr: string ): Promise { const members = (attributes.member as string | string[] | undefined) || []; const memberList: string[] = Array.isArray(members) ? members : [members]; this.logger.debug( `Creating mailing list ${mailStr} in James with ${memberList.length} members` ); // Get email addresses for all members const memberMails = await this.getMemberEmails(memberList); // Add each member to the James address group (parallelize) await Promise.all( memberMails.map(memberMail => this.callWebAdminApi( 'ldapgroupadddone:mailingList', `${this.webadminUrl}/address/groups/${mailStr}/${memberMail}`, 'PUT', dn, null, { groupMail: mailStr, memberMail } ) ) ); } /** * Create a team mailbox in James */ private async createTeamMailbox( dn: string, attributes: AttributesList, mailStr: string ): Promise { const domain = this.extractMailDomain(mailStr); if (!domain) { this.logger.error({ plugin: this.name, event: 'createTeamMailbox', dn, mail: mailStr, error: 'Cannot extract domain from mail address', }); return; } const members = (attributes.member as string | string[] | undefined) || []; const memberList: string[] = Array.isArray(members) ? members : [members]; this.logger.debug( `Creating team mailbox ${mailStr} in James with ${memberList.length} members` ); // Get email addresses for all members const memberMails = await this.getMemberEmails(memberList); // Create team mailbox and add members (parallelize) const operations: Promise[] = []; // Create the team mailbox itself operations.push( this.callWebAdminApi( 'ldapgroupadddone:teamMailbox:create', `${this.webadminUrl}/domains/${domain}/team-mailboxes/${mailStr}`, 'PUT', dn, null, { domain, teamMailbox: mailStr } ) ); // Add each member for (const memberMail of memberMails) { operations.push( this.callWebAdminApi( 'ldapgroupadddone:teamMailbox:addMember', `${this.webadminUrl}/domains/${domain}/team-mailboxes/${mailStr}/members/${memberMail}`, 'PUT', dn, null, { domain, teamMailbox: mailStr, memberMail } ) ); } await Promise.all(operations); } /** * Delete a team mailbox from James */ private async deleteTeamMailbox(dn: string, mailStr: string): Promise { const domain = this.extractMailDomain(mailStr); if (!domain) { this.logger.error({ plugin: this.name, event: 'deleteTeamMailbox', dn, mail: mailStr, error: 'Cannot extract domain from mail address', }); return; } await this.callWebAdminApi( 'ldapgroupdeletedone:teamMailbox', `${this.webadminUrl}/domains/${domain}/team-mailboxes/${mailStr}`, 'DELETE', dn, null, { domain, teamMailbox: mailStr } ); } /** * Remove all members from a team mailbox (without deleting it) * This preserves the mailbox for potential future use */ private async removeAllTeamMailboxMembers( dn: string, mailStr: string ): Promise { const domain = this.extractMailDomain(mailStr); if (!domain) { this.logger.error({ plugin: this.name, event: 'removeAllTeamMailboxMembers', dn, mail: mailStr, error: 'Cannot extract domain from mail address', }); return; } // Get current members from LDAP try { const result = (await this.server.ldap.search( { paged: false, scope: 'base', attributes: ['member'] }, dn )) as SearchResult; if (result.searchEntries && result.searchEntries.length > 0) { const members = (result.searchEntries[0].member as string | string[] | undefined) || []; const memberList: string[] = Array.isArray(members) ? members : [members]; const memberMails = await this.getMemberEmails(memberList); // Remove each member await Promise.all( memberMails.map(memberMail => this.callWebAdminApi( 'removeAllTeamMailboxMembers', `${this.webadminUrl}/domains/${domain}/team-mailboxes/${mailStr}/members/${memberMail}`, 'DELETE', dn, null, { domain, teamMailbox: mailStr, memberMail } ) ) ); this.logger.info({ plugin: this.name, event: 'removeAllTeamMailboxMembers', dn, teamMailbox: mailStr, membersRemoved: memberMails.length, message: 'All members removed from team mailbox (mailbox preserved)', }); } } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); this.logger.error( `Failed to remove members from team mailbox ${mailStr}: ${errorMsg}` ); } } /** * Delete a mailing list from James */ private async deleteMailingList(dn: string, mailStr: string): Promise { await this.callWebAdminApi( 'deleteMailingList', `${this.webadminUrl}/address/groups/${mailStr}`, 'DELETE', dn, null, { groupMail: mailStr } ); } /** * Get email addresses for a list of member DNs * Uses p-limit to parallelize LDAP queries while limiting concurrency */ async getMemberEmails(memberDns: string[]): Promise { // Create promises for each member DN, with global concurrency limit const emailPromises = memberDns .filter(memberDn => memberDn !== this.config.group_dummy_user) .map(memberDn => this.server.ldap.queryLimit(async () => { const entry = await this.ldapGetAttributes(memberDn, [this.mailAttr]); return entry ? this.attributeToString(entry[this.mailAttr]) : null; }) ); const results = await Promise.all(emailPromises); return results.filter((email): email is string => email !== null); } /** * Get the mail address for a group DN */ async getGroupMail(groupDn: string): Promise { const entry = await this.ldapGetAttributes(groupDn, ['mail']); return entry ? this.attributeToString(entry.mail) : null; } /** * Get the mailbox type for a group DN */ async getGroupMailboxType( groupDn: string ): Promise<'group' | 'mailingList' | 'teamMailbox' | null> { try { const result = (await this.server.ldap.search( { paged: false, scope: 'base', attributes: [this.mailboxTypeAttr] }, groupDn )) as SearchResult; if (result.searchEntries && result.searchEntries.length > 0) { return this.getMailboxType(result.searchEntries[0]); } } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); this.logger.debug( `Could not get mailbox type for group ${groupDn}: ${errorMsg}` ); } return null; } async _handleDelegationChange( dn: string, changes: ChangesToNotify ): Promise { // Get the user's mail attribute from LDAP (only fetch mail attribute) const entry = await this.ldapGetAttributes(dn, [this.mailAttr]); if (!entry) { this.logger.warn({ plugin: this.name, event: 'onLdapChange', dn, message: 'Could not find user entry to get mail attribute', }); return; } const userMail = entry[this.mailAttr]; if (!userMail || typeof userMail !== 'string') { this.logger.warn({ plugin: this.name, event: 'onLdapChange', dn, message: 'User has no mail attribute, cannot manage delegation', }); return; } const delegationAttr = this.config.delegation_attribute; if (!delegationAttr) return; const [oldDelegated, newDelegated] = changes[delegationAttr] || []; // Normalize values to arrays of DNs const oldDNs = this._normalizeToArray(oldDelegated as string); const newDNs = this._normalizeToArray(newDelegated as string); // Find added and removed delegations const addedDNs = newDNs.filter(delegateDN => !oldDNs.includes(delegateDN)); const removedDNs = oldDNs.filter( delegateDN => !newDNs.includes(delegateDN) ); // Fetch all delegate emails in parallel with global concurrency limit const addedEmailsPromises = addedDNs.map(delegateDN => this.server.ldap.queryLimit(async () => { const email = await this._getDelegateEmail(delegateDN); return { dn: delegateDN, email }; }) ); const removedEmailsPromises = removedDNs.map(delegateDN => this.server.ldap.queryLimit(async () => { const email = await this._getDelegateEmail(delegateDN); return { dn: delegateDN, email }; }) ); const [addedResults, removedResults] = await Promise.all([ Promise.all(addedEmailsPromises), Promise.all(removedEmailsPromises), ]); // Process additions and removals in parallel const operations: Promise[] = []; for (const { dn: delegateDN, email: delegateEmail } of addedResults) { if (delegateEmail) { operations.push( this.callWebAdminApi( 'onLdapChange:addDelegation', `${this.webadminUrl}/users/${userMail}/authorizedUsers/${delegateEmail}`, 'PUT', dn, null, { userMail, delegateEmail, delegateDN, action: 'add' } ) ); } } for (const { dn: delegateDN, email: delegateEmail } of removedResults) { if (delegateEmail) { operations.push( this.callWebAdminApi( 'onLdapChange:removeDelegation', `${this.webadminUrl}/users/${userMail}/authorizedUsers/${delegateEmail}`, 'DELETE', dn, null, { userMail, delegateEmail, delegateDN, action: 'remove' } ) ); } } await Promise.all(operations); } async _getDelegateEmail(dn: string): Promise { const entry = await this.ldapGetAttributes(dn, [this.mailAttr]); if (!entry) { this.logger.warn({ plugin: this.name, event: 'getDelegateEmail', dn, message: 'Could not resolve delegate DN to email', }); return null; } return this.attributeToString(entry[this.mailAttr]); } _normalizeToArray(value: AttributeValue | null): string[] { if (!value) return []; if (Array.isArray(value)) return value as string[]; return [value as string]; } } linagora-ldap-rest-16e557e/src/plugins/weblogs.ts000066400000000000000000000041401522642357000220310ustar00rootroot00000000000000import type { Express, Request, Response } from 'express'; import DmPlugin, { type Role } from '../abstract/plugin'; import { DmRequest } from '../lib/auth/base'; export default class WebLogs extends DmPlugin { name = 'weblogs'; roles: Role[] = ['logging'] as const; api(app: Express): void { app.use((req: DmRequest, res, next) => { let nd = true; this.logger.debug(`Incoming request: ${req.method} ${req.originalUrl}`); const start = Date.now(); res.on('finish', () => { const log: Record = {}; // Use proxyAuthUser (from trusted proxy) if available, otherwise use authenticated user if (req.proxyAuthUser) { log.user = req.proxyAuthUser; } else if (req.user) { log.user = req.user; } if (nd) this.log(req, res, start, log); nd = false; }); res.on('error', err => { if (nd) this.log(req, res, start, { error: err.message }); nd = false; }); res.on('close', () => { if (!res.writableEnded) { if (nd) this.log(req, res, start, { error: `Connection closed before response was sent for ${req.method} ${req.originalUrl}`, }); nd = false; } }); next(); }); } log( req: Request, res: Response, start: number, log: Record ): void { const duration = Date.now() - start; const _log = { method: req.method, url: req.originalUrl, status: res.statusCode, duration, ip: this.getClientIp(req), ...log, }; this.logger.notice(_log); } private getClientIp(req: Request): string { // Use X-Forwarded-For if available (already sanitized by trustedProxy plugin) const forwarded = req.headers['x-forwarded-for']; if (forwarded) { const forwardedStr = Array.isArray(forwarded) ? forwarded[0] : forwarded; // Take the first IP (original client) from the chain return forwardedStr.split(',')[0].trim(); } return req.socket.remoteAddress || 'unknown'; } } linagora-ldap-rest-16e557e/static/000077500000000000000000000000001522642357000170375ustar00rootroot00000000000000linagora-ldap-rest-16e557e/static/schemas/000077500000000000000000000000001522642357000204625ustar00rootroot00000000000000linagora-ldap-rest-16e557e/static/schemas/ad/000077500000000000000000000000001522642357000210465ustar00rootroot00000000000000linagora-ldap-rest-16e557e/static/schemas/ad/computers.json000066400000000000000000000037001522642357000237620ustar00rootroot00000000000000{ "entity": { "name": "adComputer", "mainAttribute": "sAMAccountName", "objectClass": [ "top", "person", "organizationalPerson", "user", "computer" ], "singularName": "computer", "pluralName": "computers", "base": "ou=computers,__ldap_base__" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "person", "organizationalPerson", "user", "computer"], "required": true, "fixed": true }, "cn": { "type": "string", "required": true, "role": "displayName" }, "sAMAccountName": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,15}\\$$", "required": true, "role": "identifier" }, "dNSHostName": { "type": "string", "test": "^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", "required": false, "role": "hostname" }, "servicePrincipalName": { "type": "array", "items": { "type": "string", "test": "^[^/]+/[^/]+$" }, "required": false, "role": "kerberosPrincipals" }, "userAccountControl": { "type": "number", "required": false }, "operatingSystem": { "type": "string", "required": false }, "operatingSystemVersion": { "type": "string", "required": false }, "operatingSystemServicePack": { "type": "string", "required": false }, "description": { "type": "string", "required": false }, "location": { "type": "string", "required": false }, "managedBy": { "type": "pointer", "branch": ["ou=users,__LDAP_BASE__"], "required": false }, "lastLogonTimestamp": { "type": "number", "required": false }, "pwdLastSet": { "type": "number", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/ad/groups.json000066400000000000000000000021251522642357000232600ustar00rootroot00000000000000{ "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "group"], "required": true, "fixed": true }, "cn": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]{0,254}$", "required": true }, "sAMAccountName": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,256}$", "required": true }, "description": { "type": "string", "required": false }, "mail": { "type": "string", "test": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", "required": false }, "member": { "type": "array", "items": { "type": "string", "test": "^CN=.*,__LDAP_BASE__$" }, "required": false }, "groupType": { "type": "number", "required": false }, "managedBy": { "type": "string", "test": "^CN=.*,__LDAP_BASE__", "required": false }, "info": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/ad/organizations.json000066400000000000000000000023451522642357000246340ustar00rootroot00000000000000{ "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "organizationalUnit"], "required": true, "fixed": true }, "ou": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true }, "name": { "type": "string", "required": false }, "description": { "type": "string", "required": false }, "managedBy": { "type": "string", "test": "^CN=.*,__LDAP_BASE__", "required": false }, "street": { "type": "string", "required": false }, "l": { "type": "string", "required": false }, "st": { "type": "string", "required": false }, "postalCode": { "type": "string", "required": false }, "c": { "type": "string", "test": "^[A-Z]{2}$", "required": false }, "co": { "type": "string", "required": false }, "telephoneNumber": { "type": "string", "required": false }, "facsimileTelephoneNumber": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/ad/users.json000066400000000000000000000046411522642357000231070ustar00rootroot00000000000000{ "entity": { "name": "adUser", "mainAttribute": "sAMAccountName", "objectClass": ["top", "person", "organizationalPerson", "user"], "singularName": "user", "pluralName": "users", "base": "ou=users,__ldap_base__" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "person", "organizationalPerson", "user"], "required": true, "fixed": true }, "cn": { "type": "string", "required": true, "role": "displayName" }, "sAMAccountName": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,20}$", "required": true, "role": "identifier" }, "userPrincipalName": { "type": "string", "test": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", "required": false }, "sn": { "type": "string", "required": true }, "givenName": { "type": "string", "required": false }, "displayName": { "type": "string", "required": false }, "mail": { "type": "string", "test": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", "required": false, "role": "primaryEmail" }, "proxyAddresses": { "type": "array", "items": { "type": "string", "test": "^(smtp|SMTP):[^@\\s]+@[^@\\s]+\\.[^@\\s]+$" }, "required": false, "role": "emailAliases" }, "mailQuota": { "type": "number", "required": false, "role": "emailQuota" }, "telephoneNumber": { "type": "string", "required": false }, "mobile": { "type": "string", "required": false }, "description": { "type": "string", "required": false }, "department": { "type": "string", "required": false }, "company": { "type": "string", "required": false }, "title": { "type": "string", "required": false }, "manager": { "type": "string", "test": "^CN=.*,__LDAP_BASE__", "required": false }, "userAccountControl": { "type": "number", "required": false }, "unicodePwd": { "type": "string", "required": false }, "accountExpires": { "type": "number", "required": false }, "pwdLastSet": { "type": "number", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/obm/000077500000000000000000000000001522642357000212375ustar00rootroot00000000000000linagora-ldap-rest-16e557e/static/schemas/obm/organizations.json000066400000000000000000000007171522642357000250260ustar00rootroot00000000000000{ "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "organizationalUnit"], "required": true, "fixed": true }, "ou": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true }, "description": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/obm/users.json000066400000000000000000000072431522642357000233010ustar00rootroot00000000000000{ "entity": { "name": "obmUser", "mainAttribute": "uid", "objectClass": [ "top", "inetOrgPerson", "posixAccount", "shadowAccount", "sambaSamAccount", "obmUser" ], "singularName": "user", "pluralName": "users", "base": "ou=users,__ldap_base__" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": [ "top", "inetOrgPerson", "posixAccount", "shadowAccount", "sambaSamAccount", "obmUser" ], "required": true, "fixed": true }, "uid": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true, "role": "identifier" }, "uidNumber": { "type": "number", "required": true }, "gidNumber": { "type": "number", "required": true }, "cn": { "type": "string", "required": true }, "sn": { "type": "string", "required": true }, "homeDirectory": { "type": "string", "required": false }, "mail": { "type": "string", "test": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", "required": true, "role": "primaryEmail" }, "mailAlias": { "type": "array", "items": { "type": "string", "test": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$" }, "required": false, "role": "emailAliases" }, "givenName": { "type": "string", "required": true }, "displayName": { "type": "string", "required": true }, "loginShell": { "type": "string", "required": false }, "title": { "type": "string", "required": false }, "personalTitle": { "type": "string", "required": false }, "ou": { "type": "string", "required": true }, "webAccess": { "type": "string", "test": "^(PERMIT|REJECT)$", "required": false }, "mailBox": { "type": "string", "required": true }, "mailBoxServer": { "type": "string", "required": false }, "mailAccess": { "type": "string", "test": "^(PERMIT|REJECT)$", "required": true }, "registeredAddress": { "type": "string", "required": false }, "l": { "type": "string", "required": false }, "hiddenUser": { "type": "string", "test": "^(TRUE|FALSE)$", "required": false }, "obmDomain": { "type": "string", "required": true }, "telephoneNumber": { "type": "array", "items": { "type": "string" }, "required": false }, "mobile": { "type": "string", "required": false }, "userPassword": { "type": "string", "required": false }, "sambaAcctFlags": { "type": "string", "required": false }, "sambaSID": { "type": "string", "required": false }, "sambaPrimaryGroupSID": { "type": "string", "required": false }, "sambaHomeDrive": { "type": "string", "required": false }, "sambaHomePath": { "type": "string", "required": false }, "sambaProfilePath": { "type": "string", "required": false }, "sambaLMPassword": { "type": "string", "required": false }, "sambaNTPassword": { "type": "string", "required": false }, "sambaPwdLastSet": { "type": "number", "required": false }, "jpegPhoto": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/scim/000077500000000000000000000000001522642357000214155ustar00rootroot00000000000000linagora-ldap-rest-16e557e/static/schemas/scim/Group.json000066400000000000000000000014031522642357000234020ustar00rootroot00000000000000{ "id": "urn:ietf:params:scim:schemas:core:2.0:Group", "name": "Group", "description": "Group (RFC 7643)", "attributes": [ { "name": "displayName", "type": "string", "multiValued": false, "required": true, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "server" }, { "name": "members", "type": "complex", "multiValued": true, "required": false, "mutability": "readWrite", "returned": "default", "subAttributes": [ { "name": "value", "type": "string" }, { "name": "$ref", "type": "reference" }, { "name": "display", "type": "string" }, { "name": "type", "type": "string" } ] } ] } linagora-ldap-rest-16e557e/static/schemas/scim/User.json000066400000000000000000000071651522642357000232370ustar00rootroot00000000000000{ "id": "urn:ietf:params:scim:schemas:core:2.0:User", "name": "User", "description": "User Account (RFC 7643)", "attributes": [ { "name": "userName", "type": "string", "multiValued": false, "required": true, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "server" }, { "name": "name", "type": "complex", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "subAttributes": [ { "name": "formatted", "type": "string" }, { "name": "familyName", "type": "string" }, { "name": "givenName", "type": "string" }, { "name": "middleName", "type": "string" }, { "name": "honorificPrefix", "type": "string" }, { "name": "honorificSuffix", "type": "string" } ] }, { "name": "displayName", "type": "string", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default" }, { "name": "nickName", "type": "string", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default" }, { "name": "title", "type": "string", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default" }, { "name": "userType", "type": "string", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default" }, { "name": "preferredLanguage", "type": "string", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default" }, { "name": "locale", "type": "string", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default" }, { "name": "timezone", "type": "string", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default" }, { "name": "active", "type": "boolean", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default" }, { "name": "password", "type": "string", "multiValued": false, "required": false, "mutability": "writeOnly", "returned": "never" }, { "name": "emails", "type": "complex", "multiValued": true, "required": false, "mutability": "readWrite", "returned": "default", "subAttributes": [ { "name": "value", "type": "string" }, { "name": "display", "type": "string" }, { "name": "type", "type": "string" }, { "name": "primary", "type": "boolean" } ] }, { "name": "phoneNumbers", "type": "complex", "multiValued": true, "required": false, "mutability": "readWrite", "returned": "default", "subAttributes": [ { "name": "value", "type": "string" }, { "name": "display", "type": "string" }, { "name": "type", "type": "string" }, { "name": "primary", "type": "boolean" } ] }, { "name": "groups", "type": "complex", "multiValued": true, "required": false, "mutability": "readOnly", "returned": "default", "subAttributes": [ { "name": "value", "type": "string" }, { "name": "$ref", "type": "reference" }, { "name": "display", "type": "string" }, { "name": "type", "type": "string" } ] } ] } linagora-ldap-rest-16e557e/static/schemas/scim/default-mapping.json000066400000000000000000000026061522642357000253710ustar00rootroot00000000000000{ "_comment": "Default LDAP <-> SCIM mapping for inetOrgPerson (User) and groupOfNames (Group). Override individual entries via --scim-user-mapping / --scim-group-mapping with a file using this shape.", "user": { "resourceType": "User", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "entries": [ { "scim": "userName", "ldap": "uid" }, { "scim": "externalId", "ldap": "employeeNumber" }, { "scim": "name", "sub": { "familyName": "sn", "givenName": "givenName", "formatted": "cn", "middleName": "initials" } }, { "scim": "displayName", "ldap": "displayName" }, { "scim": "title", "ldap": "title" }, { "scim": "preferredLanguage", "ldap": "preferredLanguage" }, { "scim": "emails", "ldapPrimary": "mail", "ldapSecondary": "mailAlternateAddress", "multi": "array" }, { "scim": "phoneNumbers", "ldapPrimary": "telephoneNumber", "ldapSecondary": "mobile", "multi": "array" } ] }, "group": { "resourceType": "Group", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "entries": [ { "scim": "displayName", "ldap": "cn" }, { "scim": "externalId", "ldap": "entryUUID", "operational": true, "readOnly": true } ] } } linagora-ldap-rest-16e557e/static/schemas/standard/000077500000000000000000000000001522642357000222625ustar00rootroot00000000000000linagora-ldap-rest-16e557e/static/schemas/standard/automountMaps.json000066400000000000000000000014401522642357000260300ustar00rootroot00000000000000{ "entity": { "name": "automount", "mainAttribute": "automountKey", "objectClass": ["top", "automount"], "singularName": "automount", "pluralName": "automounts", "base": "ou=automount,__ldap_base__" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "automount"], "required": true, "fixed": true }, "automountKey": { "type": "string", "test": "^[a-zA-Z0-9/_.*-]+$", "required": true, "role": "identifier" }, "automountInformation": { "type": "string", "required": true }, "description": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/standard/devices.json000066400000000000000000000041671522642357000246070ustar00rootroot00000000000000{ "entity": { "name": "standardDevice", "mainAttribute": "cn", "objectClass": ["top", "device", "ipHost"], "singularName": "device", "pluralName": "devices", "base": "ou=devices,__ldap_base__" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "device", "ipHost"], "required": true, "fixed": true }, "cn": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true, "role": "identifier" }, "ipHostNumber": { "type": "array", "items": { "type": "string", "test": "^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$|^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$" }, "required": false, "role": "ipAddresses" }, "description": { "type": "string", "required": false }, "l": { "type": "string", "required": false, "role": "location" }, "serialNumber": { "type": "string", "required": false }, "seeAlso": { "type": "array", "items": { "type": "string" }, "required": false }, "owner": { "type": "pointer", "branch": ["ou=users,__LDAP_BASE__"], "required": false }, "ou": { "type": "string", "required": false }, "manager": { "type": "pointer", "branch": ["ou=users,__LDAP_BASE__"], "required": false } } } linagora-ldap-rest-16e557e/static/schemas/standard/dhcpHosts.json000066400000000000000000000020761522642357000251210ustar00rootroot00000000000000{ "entity": { "name": "dhcpHost", "mainAttribute": "cn", "objectClass": ["top", "dhcpHost"], "singularName": "dhcpHost", "pluralName": "dhcpHosts", "base": "ou=dhcp,__ldap_base__" }, "strict": false, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "dhcpHost"], "required": true, "fixed": true }, "cn": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true, "role": "identifier" }, "dhcpHWAddress": { "type": "string", "test": "^ethernet ([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$", "required": true }, "dhcpStatements": { "type": "array", "items": { "type": "string" }, "required": false }, "dhcpOption": { "type": "array", "items": { "type": "string" }, "required": false }, "dhcpComments": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/standard/dnsRecords.json000066400000000000000000000036371522642357000252740ustar00rootroot00000000000000{ "entity": { "name": "dnsRecord", "mainAttribute": "dc", "objectClass": ["top", "dNSDomain2"], "singularName": "dnsRecord", "pluralName": "dnsRecords", "base": "ou=dns,__ldap_base__" }, "strict": false, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "dNSDomain2"], "required": true, "fixed": true }, "dc": { "type": "string", "test": "^[a-zA-Z0-9@._*-]+$", "required": true, "role": "identifier" }, "dNSTTL": { "type": "number", "required": false }, "dNSClass": { "type": "string", "default": "IN", "required": false }, "aRecord": { "type": "array", "items": { "type": "string", "test": "^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$" }, "required": false }, "aAAARecord": { "type": "array", "items": { "type": "string" }, "required": false }, "mXRecord": { "type": "array", "items": { "type": "string" }, "required": false }, "nSRecord": { "type": "array", "items": { "type": "string" }, "required": false }, "cNAMERecord": { "type": "array", "items": { "type": "string" }, "required": false }, "pTRRecord": { "type": "array", "items": { "type": "string" }, "required": false }, "tXTRecord": { "type": "array", "items": { "type": "string" }, "required": false }, "sRVRecord": { "type": "array", "items": { "type": "string" }, "required": false }, "sOARecord": { "type": "array", "items": { "type": "string" }, "required": false } } } linagora-ldap-rest-16e557e/static/schemas/standard/groups.json000066400000000000000000000021371522642357000244770ustar00rootroot00000000000000{ "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "groupOfNames"], "required": true, "fixed": true }, "cn": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]{0,254}$", "required": true }, "description": { "type": "string", "required": false }, "mail": { "type": "string", "test": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", "required": false }, "member": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z]+=.*,__LDAP_BASE__$" }, "required": false }, "owner": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z]+=[a-zA-Z][a-zA-Z0-9-]*,__LDAP_BASE__$" }, "required": false }, "businessCategory": { "type": "string", "test": "^.*,__LDAP_BASE__", "required": false }, "ou": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/standard/netgroups.json000066400000000000000000000017621522642357000252110ustar00rootroot00000000000000{ "entity": { "name": "netgroup", "mainAttribute": "cn", "objectClass": ["top", "nisNetgroup"], "singularName": "netgroup", "pluralName": "netgroups", "base": "ou=netgroups,__ldap_base__" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "nisNetgroup"], "required": true, "fixed": true }, "cn": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true, "role": "identifier" }, "nisNetgroupTriple": { "type": "array", "items": { "type": "string", "test": "^\\([^,]*,[^,]*,[^)]*\\)$" }, "required": false }, "memberNisNetgroup": { "type": "array", "items": { "type": "string" }, "required": false }, "description": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/standard/organizations.json000066400000000000000000000017041522642357000260460ustar00rootroot00000000000000{ "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "organizationalUnit"], "required": true, "fixed": true }, "ou": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true }, "description": { "type": "string", "required": false }, "telephoneNumber": { "type": "string", "required": false }, "facsimileTelephoneNumber": { "type": "string", "required": false }, "l": { "type": "string", "required": false }, "postalAddress": { "type": "string", "required": false }, "businessCategory": { "type": "string", "test": "^.*,__LDAP_BASE__", "required": false }, "seeAlso": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/standard/posixAccounts.json000066400000000000000000000035211522642357000260200ustar00rootroot00000000000000{ "entity": { "name": "posixAccount", "mainAttribute": "uid", "objectClass": ["top", "posixAccount", "shadowAccount"], "singularName": "posixAccount", "pluralName": "posixAccounts", "base": "ou=users,__ldap_base__" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "posixAccount", "shadowAccount"], "required": true, "fixed": true }, "uid": { "type": "string", "test": "^[a-z_][a-z0-9_-]{0,31}$", "required": true, "role": "identifier" }, "cn": { "type": "string", "required": true }, "uidNumber": { "type": "number", "required": true }, "gidNumber": { "type": "number", "required": true }, "homeDirectory": { "type": "string", "test": "^/.*$", "required": true }, "loginShell": { "type": "string", "test": "^/.*$", "default": "/bin/bash", "required": false }, "gecos": { "type": "string", "required": false }, "description": { "type": "string", "required": false }, "userPassword": { "type": "string", "required": false }, "shadowLastChange": { "type": "number", "required": false }, "shadowMin": { "type": "number", "required": false }, "shadowMax": { "type": "number", "required": false }, "shadowWarning": { "type": "number", "required": false }, "shadowInactive": { "type": "number", "required": false }, "shadowExpire": { "type": "number", "required": false }, "shadowFlag": { "type": "number", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/standard/posixGroups.json000066400000000000000000000017751522642357000255310ustar00rootroot00000000000000{ "entity": { "name": "posixGroup", "mainAttribute": "cn", "objectClass": ["top", "posixGroup"], "singularName": "posixGroup", "pluralName": "posixGroups", "base": "ou=groups,__ldap_base__" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "posixGroup"], "required": true, "fixed": true }, "cn": { "type": "string", "test": "^[a-z_][a-z0-9_-]{0,31}$", "required": true, "role": "identifier" }, "gidNumber": { "type": "number", "required": true }, "memberUid": { "type": "array", "items": { "type": "string", "test": "^[a-z_][a-z0-9_-]{0,31}$" }, "required": false }, "description": { "type": "string", "required": false }, "userPassword": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/standard/sshPublicKeys.json000066400000000000000000000022441522642357000257470ustar00rootroot00000000000000{ "entity": { "name": "sshPublicKey", "mainAttribute": "uid", "objectClass": ["top", "ldapPublicKey", "inetOrgPerson"], "singularName": "sshPublicKey", "pluralName": "sshPublicKeys", "base": "ou=users,__ldap_base__" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "ldapPublicKey", "inetOrgPerson"], "required": true, "fixed": true }, "uid": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true, "role": "identifier" }, "cn": { "type": "string", "required": true }, "sn": { "type": "string", "required": true }, "sshPublicKey": { "type": "array", "items": { "type": "string", "test": "^(ssh-rsa|ssh-dss|ssh-ed25519|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521|sk-ssh-ed25519@openssh\\.com|sk-ecdsa-sha2-nistp256@openssh\\.com) .*$" }, "required": false }, "description": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/standard/sudoRules.json000066400000000000000000000032111522642357000251370ustar00rootroot00000000000000{ "entity": { "name": "sudoRule", "mainAttribute": "cn", "objectClass": ["top", "sudoRole"], "singularName": "sudoRule", "pluralName": "sudoRules", "base": "ou=sudoers,__ldap_base__" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "sudoRole"], "required": true, "fixed": true }, "cn": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true, "role": "identifier" }, "sudoUser": { "type": "array", "items": { "type": "string" }, "required": true }, "sudoHost": { "type": "array", "items": { "type": "string" }, "required": true }, "sudoCommand": { "type": "array", "items": { "type": "string" }, "required": true }, "sudoOption": { "type": "array", "items": { "type": "string" }, "required": false }, "sudoRunAsUser": { "type": "array", "items": { "type": "string" }, "required": false }, "sudoRunAsGroup": { "type": "array", "items": { "type": "string" }, "required": false }, "sudoNotBefore": { "type": "string", "required": false }, "sudoNotAfter": { "type": "string", "required": false }, "sudoOrder": { "type": "number", "required": false }, "description": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/standard/users.json000066400000000000000000000034701522642357000243220ustar00rootroot00000000000000{ "entity": { "name": "standardUser", "mainAttribute": "uid", "objectClass": ["top", "inetOrgPerson", "organizationalPerson", "person"], "singularName": "user", "pluralName": "users", "base": "ou=users,__ldap_base__" }, "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 }, "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": "^.*,__LDAP_BASE__", "required": false }, "ou": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/twake/000077500000000000000000000000001522642357000215755ustar00rootroot00000000000000linagora-ldap-rest-16e557e/static/schemas/twake/devices.json000077700000000000000000000000001522642357000304352../standard/devices.jsonustar00rootroot00000000000000linagora-ldap-rest-16e557e/static/schemas/twake/groups.json000066400000000000000000000033661522642357000240170ustar00rootroot00000000000000{ "entity": { "name": "twakeGroup", "mainAttribute": "cn", "objectClass": ["top", "groupOfNames", "twakeStaticGroup"], "singularName": "group", "pluralName": "groups", "base": "ou=groups,__ldap_base__" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "groupOfNames", "twakeStaticGroup"], "required": true, "fixed": true }, "cn": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]{0,254}$", "required": true, "role": "identifier" }, "description": { "type": "string", "required": false }, "mail": { "type": "string", "test": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", "required": false, "role": "primaryEmail" }, "twakeMailboxType": { "type": "pointer", "branch": ["ou=twakeMailboxType,ou=nomenclature,__LDAP_BASE__"], "group": "Mailbox Settings", "required": false }, "member": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z]+=.*,__LDAP_BASE__$" }, "required": false, "role": "members" }, "owner": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z]+=[a-zA-Z][a-zA-Z0-9-]*,__LDAP_BASE__$" }, "required": false, "role": "owners" }, "twakeDepartmentLink": { "type": "string", "test": "^.*,__LDAP_BASE__", "required": true, "role": "organizationLink" }, "twakeDepartmentPath": { "type": "string", "test": "^[\\w\\s/,]+$", "required": true, "role": "organizationPath" } } } linagora-ldap-rest-16e557e/static/schemas/twake/nomenclature/000077500000000000000000000000001522642357000242715ustar00rootroot00000000000000linagora-ldap-rest-16e557e/static/schemas/twake/nomenclature/twakeAccountStatus.json000066400000000000000000000012571522642357000310250ustar00rootroot00000000000000{ "entity": { "name": "twakeAccountStatus", "mainAttribute": "cn", "objectClass": ["top", "applicationProcess"], "singularName": "accountStatus", "pluralName": "accountStatuses", "base": "ou=twakeAccountStatus,ou=nomenclature,{ldap_base}" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string" }, "default": ["top", "applicationProcess"], "required": true, "fixed": true }, "cn": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9]+$", "required": true }, "description": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/twake/nomenclature/twakeDeliveryMode.json000066400000000000000000000012521522642357000306100ustar00rootroot00000000000000{ "entity": { "name": "twakeDeliveryMode", "mainAttribute": "cn", "objectClass": ["top", "applicationProcess"], "singularName": "deliveryMode", "pluralName": "deliveryModes", "base": "ou=twakeDeliveryMode,ou=nomenclature,{ldap_base}" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string" }, "default": ["top", "applicationProcess"], "required": true, "fixed": true }, "cn": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9]+$", "required": true }, "description": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/twake/nomenclature/twakeListType.json000066400000000000000000000012321522642357000277730ustar00rootroot00000000000000{ "entity": { "name": "twakeListType", "mainAttribute": "cn", "objectClass": ["top", "applicationProcess"], "singularName": "listType", "pluralName": "listTypes", "base": "ou=twakeListType,ou=nomenclature,{ldap_base}" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string" }, "default": ["top", "applicationProcess"], "required": true, "fixed": true }, "cn": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9]+$", "required": true }, "description": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/twake/nomenclature/twakeMailboxType.json000066400000000000000000000012611522642357000304550ustar00rootroot00000000000000{ "entity": { "name": "twakeMailboxType", "mainAttribute": "cn", "objectClass": ["top", "applicationProcess"], "singularName": "mailboxType", "pluralName": "mailboxTypes", "base": "ou=twakeMailboxType,ou=nomenclature,{ldap_base}" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string" }, "default": ["top", "applicationProcess"], "required": true, "fixed": true }, "cn": { "type": "string", "test": "^(group|mailingList|teamMailbox)$", "required": true }, "description": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/twake/nomenclature/twakeTitle.json000066400000000000000000000012201522642357000272740ustar00rootroot00000000000000{ "entity": { "name": "twakeTitle", "mainAttribute": "cn", "objectClass": ["top", "applicationProcess"], "singularName": "title", "pluralName": "titles", "base": "ou=twakeTitle,ou=nomenclature,{ldap_base}" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string" }, "default": ["top", "applicationProcess"], "required": true, "fixed": true }, "cn": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9 .]+$", "required": true }, "description": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/twake/organizations.json000066400000000000000000000020231522642357000253540ustar00rootroot00000000000000{ "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "organizationalUnit", "twakeDepartment"], "required": true, "fixed": true }, "ou": { "type": "string", "test": "^[a-zA-Z0-9._-]{1,255}$", "required": true }, "description": { "type": "string", "required": false }, "telephoneNumber": { "type": "string", "required": false }, "facsimileTelephoneNumber": { "type": "string", "required": false }, "l": { "type": "string", "required": false }, "postalAddress": { "type": "string", "required": false }, "twakeDepartmentPath": { "type": "string", "test": "^[\\w\\s/,]+$", "required": true }, "twakeLocalAdminLink": { "type": "array", "items": { "type": "string" }, "required": false } } } linagora-ldap-rest-16e557e/static/schemas/twake/positions.json000066400000000000000000000012451522642357000245210ustar00rootroot00000000000000{ "entity": { "name": "twakePosition", "mainAttribute": "cn", "objectClass": ["top", "twakePosition"], "singularName": "position", "pluralName": "positions", "base": "ou=positions,{ldap_base}" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "twakePosition"], "required": true, "fixed": true }, "cn": { "type": "string", "test": "^[a-zA-Z0-9 &/,.-]+$", "required": true }, "description": { "type": "string", "required": false } } } linagora-ldap-rest-16e557e/static/schemas/twake/users.json000066400000000000000000000047411522642357000236370ustar00rootroot00000000000000{ "entity": { "name": "twakeUser", "mainAttribute": "uid", "objectClass": ["top", "twakeAccount", "twakeWhitePages"], "singularName": "user", "pluralName": "users", "base": "ou=users,__ldap_base__" }, "strict": true, "attributes": { "objectClass": { "type": "array", "items": { "type": "string", "test": "^[a-zA-Z][a-zA-Z0-9-]*$" }, "default": ["top", "twakeAccount", "twakeWhitePages"], "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 }, "twakeDepartmentLink": { "type": "string", "test": "^.*,__LDAP_BASE__", "required": true, "role": "organizationLink" }, "twakeDepartmentPath": { "type": "string", "test": "^[\\w\\s/,]+$", "required": true, "role": "organizationPath" }, "twakeAccountStatus": { "type": "pointer", "branch": ["ou=twakeAccountStatus,ou=nomenclature,__LDAP_BASE__"], "required": true }, "twakeDeliveryMode": { "type": "pointer", "branch": ["ou=twakeDeliveryMode,ou=nomenclature,__LDAP_BASE__"], "required": true }, "twakeDelegatedUsers": { "type": "array", "items": { "type": "pointer", "branch": ["ou=users,__LDAP_BASE__"] }, "required": false }, "mailQuotaSize": { "type": "integer", "required": false } } } linagora-ldap-rest-16e557e/test/000077500000000000000000000000001522642357000165275ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/README.md000066400000000000000000000063421522642357000200130ustar00rootroot00000000000000# Test Suite ## Quick Start ```bash # Run all tests (uses embedded LDAP server automatically) npm test # Run a specific test file npm run test:one test/plugins/ldap/flatGeneric.test.ts # Run with external LDAP server export DM_LDAP_URL=ldap://localhost:389 export DM_LDAP_DN=cn=admin,dc=example,dc=com export DM_LDAP_PWD=secret export DM_LDAP_BASE=dc=example,dc=com npm test ``` ## Overview The test suite automatically manages LDAP server setup: - **No LDAP configured** → Embedded Docker LDAP server starts automatically - **LDAP env vars set** → Uses your external LDAP server This means tests work: - ✅ In CI/CD without any setup - ✅ Locally without installing LDAP - ✅ With your existing LDAP server if configured ## Documentation See [Testing with Embedded LDAP](../docs/testing-with-embedded-ldap.md) for complete documentation. ## Test Structure ``` test/ ├── helpers/ │ ├── ldapServer.ts # Docker LDAP server management │ ├── env.ts # Environment detection │ └── testSetup.ts # Test helpers ├── fixtures/ │ ├── base-structure.ldif # B2C test data (always loaded) │ └── b2b-organizations.ldif # B2B test data (future use) ├── integration/ │ └── ldapServer.test.ts # Infrastructure tests ├── plugins/ │ ├── ldap/ # LDAP plugin tests │ ├── twake/ # Twake plugin tests │ └── auth/ # Auth plugin tests └── setup.ts # Global test configuration ``` ## Requirements - **Node.js** 20+ - **Docker** (for embedded LDAP server) ## Writing Tests ### Standard Test ```typescript import { expect } from 'chai'; import { DM } from '../../src/bin'; describe('My Feature', () => { let server: DM; before(async () => { server = new DM(); await server.ready; }); it('should work', async () => { // Your test here }); }); ``` ### Test with Direct LDAP Access ```typescript import { getTestLdapServer } from '../helpers/testSetup'; describe('Advanced LDAP Feature', () => { it('should query LDAP directly', async () => { const ldap = getTestLdapServer(); const result = await ldap.search('(uid=john.doe)'); expect(result).to.include('john.doe'); }); }); ``` ## Test Data Pre-loaded LDAP data: **Users:** - john.doe@example.com (password: password123) - jane.smith@example.com (password: password123) **Groups:** - cn=admins (members: john.doe, jane.smith) **Nomenclature:** - Titles: Dr, Mr, Ms - List Types: openList, memberRestrictedList - Mailbox Types: group, mailingList, teamMailbox ## Troubleshooting **Tests fail with "LDAP server not started":** ```bash # Check Docker is running docker ps # Clean up old containers docker rm -f $(docker ps -a | grep ldap-test | awk '{print $1}') ``` **Tests are slow:** - First test run starts LDAP server (~2-3s) - Subsequent tests in same run are fast - Server is shared across all tests in a single `npm test` run **Want to use external LDAP:** ```bash # Set these before running tests export DM_LDAP_URL=ldap://your-server:389 export DM_LDAP_DN=cn=admin,dc=yourdc,dc=com export DM_LDAP_PWD=your-password export DM_LDAP_BASE=dc=yourdc,dc=com ``` linagora-ldap-rest-16e557e/test/__plugins__/000077500000000000000000000000001522642357000210045ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/__plugins__/error-test/000077500000000000000000000000001522642357000231125ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/__plugins__/error-test/index.js000066400000000000000000000037241522642357000245650ustar00rootroot00000000000000import Plugin from '../../../dist/abstract/plugin.js'; import { asyncHandler, BadRequestError, NotFoundError, BadGatewayError, ServiceUnavailableError, GatewayTimeoutError, } from '../../../dist/bin/index.js'; class ErrorTestPlugin extends Plugin { name = 'errortest'; api(app) { // Route without asyncHandler - would crash without error middleware app.get('/api/error-unhandled', async (req, res) => { throw new Error('Unhandled async error'); }); // Route with asyncHandler - properly handled app.get( '/api/error-handled', asyncHandler(async (req, res) => { throw new Error('Handled async error'); }) ); // Route that throws BadRequestError (400) app.get( '/api/error-badrequest', asyncHandler(async (req, res) => { throw new BadRequestError('Invalid request parameter'); }) ); // Route that throws NotFoundError (404) app.get( '/api/error-notfound', asyncHandler(async (req, res) => { throw new NotFoundError('Resource not found'); }) ); // Route that throws BadGatewayError (502) app.get( '/api/error-badgateway', asyncHandler(async (req, res) => { throw new BadGatewayError('Upstream service failed'); }) ); // Route that throws ServiceUnavailableError (503) app.get( '/api/error-serviceunavailable', asyncHandler(async (req, res) => { throw new ServiceUnavailableError('Service temporarily unavailable'); }) ); // Route that throws GatewayTimeoutError (504) app.get( '/api/error-gatewaytimeout', asyncHandler(async (req, res) => { throw new GatewayTimeoutError('Upstream timeout'); }) ); // Route that works - to test server is still alive after errors app.get( '/api/ok', asyncHandler(async (req, res) => { res.json({ status: 'ok' }); }) ); } } export { ErrorTestPlugin as default }; linagora-ldap-rest-16e557e/test/__plugins__/hello/000077500000000000000000000000001522642357000221075ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/__plugins__/hello/index.js000066400000000000000000000007301522642357000235540ustar00rootroot00000000000000import Plugin from '../../../dist/abstract/plugin.js'; class HelloWorldPath extends Plugin { name = 'hellopath'; api(app) { console.debug('Hello plugin loaded - routes: GET /hello'); console.debug(' => I stored caller object to have hooks later'); app.get('/hellopath', (req, res) => { const response = { message: 'Hello path' }; res.json(response); }); } } export { HelloWorldPath as default }; //# sourceMappingURL=helloworld.js.map linagora-ldap-rest-16e557e/test/__plugins__/hello/package.json000066400000000000000000000001261522642357000243740ustar00rootroot00000000000000{ "name": "hello", "version": "1.0.0", "type": "module", "main": "index.js" } linagora-ldap-rest-16e557e/test/browser/000077500000000000000000000000001522642357000202125ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/browser/api-client.test.ts000066400000000000000000000177321522642357000235770ustar00rootroot00000000000000import { expect } from 'chai'; import nock from 'nock'; import { LdapApiClient } from '../../src/browser/ldap-tree-viewer/api/LdapApiClient'; describe('Browser LDAP API Client', () => { const baseUrl = 'http://localhost:8081'; let client: LdapApiClient; beforeEach(() => { client = new LdapApiClient(baseUrl); }); afterEach(() => { nock.cleanAll(); }); describe('getTopOrganization', () => { it('should fetch top organization', async () => { const mockResponse = { dn: 'ou=organization,dc=example,dc=com', ou: 'organization', }; nock(baseUrl) .get('/api/v1/ldap/organizations/top') .reply(200, mockResponse); const result = await client.getTopOrganization(); expect(result).to.deep.equal(mockResponse); }); it('should throw error on failed request', async () => { nock(baseUrl) .get('/api/v1/ldap/organizations/top') .reply(404, 'Not Found'); try { await client.getTopOrganization(); expect.fail('Should have thrown error'); } catch (error) { expect(error).to.be.instanceOf(Error); expect((error as Error).message).to.include('404'); } }); }); describe('getOrganization', () => { it('should fetch organization by DN', async () => { const dn = 'ou=test,ou=organization,dc=example,dc=com'; const encodedDn = encodeURIComponent(dn); const mockResponse = { dn, ou: 'test', }; nock(baseUrl) .get(`/api/v1/ldap/organizations/${encodedDn}`) .reply(200, mockResponse); const result = await client.getOrganization(dn); expect(result).to.deep.equal(mockResponse); }); it('should properly encode DN with special characters', async () => { const dn = 'ou=Test & Dev,ou=organization,dc=example,dc=com'; const encodedDn = encodeURIComponent(dn); const mockResponse = { dn, ou: 'Test & Dev', }; nock(baseUrl) .get(`/api/v1/ldap/organizations/${encodedDn}`) .reply(200, mockResponse); const result = await client.getOrganization(dn); expect(result).to.deep.equal(mockResponse); }); }); describe('getOrganizationSubnodes', () => { it('should fetch organization subnodes', async () => { const dn = 'ou=test,ou=organization,dc=example,dc=com'; const encodedDn = encodeURIComponent(dn); const mockResponse = [ { dn: 'ou=child1,ou=test,ou=organization,dc=example,dc=com', type: 'organization', }, { dn: 'uid=user1,ou=test,ou=organization,dc=example,dc=com', type: 'user', }, ]; nock(baseUrl) .get(`/api/v1/ldap/organizations/${encodedDn}/subnodes`) .reply(200, mockResponse); const result = await client.getOrganizationSubnodes(dn); expect(result).to.deep.equal(mockResponse); }); it('should return empty array for organization with no subnodes', async () => { const dn = 'ou=empty,ou=organization,dc=example,dc=com'; const encodedDn = encodeURIComponent(dn); nock(baseUrl) .get(`/api/v1/ldap/organizations/${encodedDn}/subnodes`) .reply(200, []); const result = await client.getOrganizationSubnodes(dn); expect(result).to.be.an('array').that.is.empty; }); }); describe('searchOrganizationSubnodes', () => { it('should search organization subnodes', async () => { const dn = 'ou=test,ou=organization,dc=example,dc=com'; const query = 'john'; const encodedDn = encodeURIComponent(dn); const encodedQuery = encodeURIComponent(query); const mockResponse = [ { dn: 'uid=john.doe,ou=test,ou=organization,dc=example,dc=com', type: 'user', }, ]; nock(baseUrl) .get( `/api/v1/ldap/organizations/${encodedDn}/subnodes/search?q=${encodedQuery}` ) .reply(200, mockResponse); const result = await client.searchOrganizationSubnodes(dn, query); expect(result).to.deep.equal(mockResponse); }); it('should properly encode search query with special characters', async () => { const dn = 'ou=test,ou=organization,dc=example,dc=com'; const query = 'test & dev'; const encodedDn = encodeURIComponent(dn); const encodedQuery = encodeURIComponent(query); nock(baseUrl) .get( `/api/v1/ldap/organizations/${encodedDn}/subnodes/search?q=${encodedQuery}` ) .reply(200, []); const result = await client.searchOrganizationSubnodes(dn, query); expect(result).to.be.an('array'); }); }); describe('getUsers', () => { it('should fetch users without filter', async () => { const mockResponse = { entries: [ { uid: 'user1', mail: 'user1@test.org' }, { uid: 'user2', mail: 'user2@test.org' }, ], total: 2, }; nock(baseUrl).get('/api/v1/ldap/users').reply(200, mockResponse); const result = await client.getUsers(); expect(result).to.deep.equal(mockResponse); }); it('should fetch users with filter', async () => { const filter = 'john'; const mockResponse = { entries: [{ uid: 'john.doe', mail: 'john@test.org' }], total: 1, }; nock(baseUrl) .get(`/api/v1/ldap/users?filter=${encodeURIComponent(filter)}`) .reply(200, mockResponse); const result = await client.getUsers(filter); expect(result).to.deep.equal(mockResponse); }); }); describe('getGroups', () => { it('should fetch groups without filter', async () => { const mockResponse = { entries: [ { cn: 'group1', mail: 'group1@test.org' }, { cn: 'group2', mail: 'group2@test.org' }, ], total: 2, }; nock(baseUrl).get('/api/v1/ldap/groups').reply(200, mockResponse); const result = await client.getGroups(); expect(result).to.deep.equal(mockResponse); }); it('should fetch groups with filter', async () => { const filter = 'admin'; const mockResponse = { entries: [{ cn: 'admins', mail: 'admins@test.org' }], total: 1, }; nock(baseUrl) .get(`/api/v1/ldap/groups?filter=${encodeURIComponent(filter)}`) .reply(200, mockResponse); const result = await client.getGroups(filter); expect(result).to.deep.equal(mockResponse); }); }); describe('Authentication', () => { it('should include Authorization header when token is provided', async () => { const token = 'test-token-123'; const clientWithAuth = new LdapApiClient(baseUrl, token); nock(baseUrl) .get('/api/v1/ldap/organizations/top') .matchHeader('Authorization', `Bearer ${token}`) .reply(200, { dn: 'ou=org,dc=example,dc=com' }); await clientWithAuth.getTopOrganization(); }); it('should not include Authorization header when no token', async () => { nock(baseUrl) .get('/api/v1/ldap/organizations/top') .matchHeader('Authorization', val => val === undefined) .reply(200, { dn: 'ou=org,dc=example,dc=com' }); await client.getTopOrganization(); }); }); describe('Error handling', () => { it('should handle 500 server errors', async () => { nock(baseUrl) .get('/api/v1/ldap/organizations/top') .reply(500, 'Internal Server Error'); try { await client.getTopOrganization(); expect.fail('Should have thrown error'); } catch (error) { expect(error).to.be.instanceOf(Error); expect((error as Error).message).to.include('500'); } }); it('should handle network errors', async () => { nock(baseUrl) .get('/api/v1/ldap/organizations/top') .replyWithError('Network error'); try { await client.getTopOrganization(); expect.fail('Should have thrown error'); } catch (error) { expect(error).to.be.instanceOf(Error); } }); }); }); linagora-ldap-rest-16e557e/test/browser/cache-manager.test.ts000066400000000000000000000153761522642357000242270ustar00rootroot00000000000000import { expect } from 'chai'; import { CacheManager } from '../../src/browser/ldap-user-editor/cache/CacheManager'; describe('Browser CacheManager', () => { let cache: CacheManager; beforeEach(() => { cache = new CacheManager({ ttl: 1000, maxEntries: 5 }); }); afterEach(() => { cache.clear(); }); describe('Basic operations', () => { it('should set and get a value', () => { cache.set('key1', { data: 'value1' }); const result = cache.get<{ data: string }>('key1'); expect(result).to.deep.equal({ data: 'value1' }); }); it('should return null for non-existent key', () => { const result = cache.get('nonexistent'); expect(result).to.be.null; }); it('should check if key exists', () => { cache.set('key1', 'value1'); expect(cache.has('key1')).to.be.true; expect(cache.has('nonexistent')).to.be.false; }); it('should handle different data types', () => { cache.set('string', 'hello'); cache.set('number', 42); cache.set('object', { a: 1, b: 2 }); cache.set('array', [1, 2, 3]); cache.set('boolean', true); expect(cache.get('string')).to.equal('hello'); expect(cache.get('number')).to.equal(42); expect(cache.get('object')).to.deep.equal({ a: 1, b: 2 }); expect(cache.get('array')).to.deep.equal([1, 2, 3]); expect(cache.get('boolean')).to.equal(true); }); }); describe('Invalidation', () => { it('should invalidate a specific key', () => { cache.set('key1', 'value1'); cache.set('key2', 'value2'); cache.invalidate('key1'); expect(cache.get('key1')).to.be.null; expect(cache.get('key2')).to.equal('value2'); }); it('should invalidate keys matching pattern', () => { cache.set('/api/users/1', { id: 1 }); cache.set('/api/users/2', { id: 2 }); cache.set('/api/groups/1', { id: 1 }); cache.invalidatePattern('/api/users/*'); expect(cache.get('/api/users/1')).to.be.null; expect(cache.get('/api/users/2')).to.be.null; expect(cache.get('/api/groups/1')).to.deep.equal({ id: 1 }); }); it('should invalidate with wildcard at start', () => { cache.set('foo/bar', 'value1'); cache.set('baz/bar', 'value2'); cache.set('foo/baz', 'value3'); cache.invalidatePattern('*/bar'); expect(cache.get('foo/bar')).to.be.null; expect(cache.get('baz/bar')).to.be.null; expect(cache.get('foo/baz')).to.equal('value3'); }); it('should clear all cache', () => { cache.set('key1', 'value1'); cache.set('key2', 'value2'); cache.set('key3', 'value3'); cache.clear(); expect(cache.get('key1')).to.be.null; expect(cache.get('key2')).to.be.null; expect(cache.get('key3')).to.be.null; }); }); describe('TTL expiration', () => { it('should return null for expired entries', async () => { const shortCache = new CacheManager({ ttl: 50 }); shortCache.set('key1', 'value1'); expect(shortCache.get('key1')).to.equal('value1'); // Wait for TTL to expire await new Promise(resolve => setTimeout(resolve, 100)); expect(shortCache.get('key1')).to.be.null; }); it('should update timestamp on get', async () => { // Use generous margins relative to TTL so this stays deterministic on // slow CI runners — under load setTimeout drift can easily push a // 50ms-vs-100ms boundary the wrong way and produce false negatives. const shortCache = new CacheManager({ ttl: 300 }); shortCache.set('key1', 'value1'); // Access well before the TTL expires (refreshes timestamp) await new Promise(resolve => setTimeout(resolve, 100)); expect(shortCache.get('key1')).to.equal('value1'); // Access again well before the (refreshed) TTL expires await new Promise(resolve => setTimeout(resolve, 100)); expect(shortCache.get('key1')).to.equal('value1'); // Wait noticeably longer than the TTL — entry must be gone now await new Promise(resolve => setTimeout(resolve, 500)); expect(shortCache.get('key1')).to.be.null; }); it('should clean expired entries', async () => { const shortCache = new CacheManager({ ttl: 50 }); shortCache.set('key1', 'value1'); shortCache.set('key2', 'value2'); await new Promise(resolve => setTimeout(resolve, 100)); const cleaned = shortCache.cleanExpired(); expect(cleaned).to.equal(2); expect(shortCache.get('key1')).to.be.null; expect(shortCache.get('key2')).to.be.null; }); }); describe('LRU eviction', () => { it('should evict LRU entry when max size is reached', () => { const lruCache = new CacheManager({ maxEntries: 3 }); lruCache.set('key1', 'value1'); lruCache.set('key2', 'value2'); lruCache.set('key3', 'value3'); // Access key1 to make it more recently used lruCache.get('key1'); // Add a new entry, should evict key2 (least recently used) lruCache.set('key4', 'value4'); expect(lruCache.get('key1')).to.equal('value1'); expect(lruCache.get('key2')).to.be.null; // Evicted expect(lruCache.get('key3')).to.equal('value3'); expect(lruCache.get('key4')).to.equal('value4'); }); it('should not evict when updating existing key', () => { const lruCache = new CacheManager({ maxEntries: 3 }); lruCache.set('key1', 'value1'); lruCache.set('key2', 'value2'); lruCache.set('key3', 'value3'); // Update key1 lruCache.set('key1', 'new-value1'); expect(lruCache.get('key1')).to.equal('new-value1'); expect(lruCache.get('key2')).to.equal('value2'); expect(lruCache.get('key3')).to.equal('value3'); }); }); describe('Statistics', () => { it('should return cache stats', () => { cache.set('key1', 'value1'); cache.set('key2', 'value2'); const stats = cache.getStats(); expect(stats.size).to.equal(2); expect(stats.maxSize).to.equal(5); expect(stats.ttl).to.equal(1000); expect(stats.keys).to.have.members(['key1', 'key2']); }); it('should update size when entries are added/removed', () => { cache.set('key1', 'value1'); expect(cache.getStats().size).to.equal(1); cache.set('key2', 'value2'); expect(cache.getStats().size).to.equal(2); cache.invalidate('key1'); expect(cache.getStats().size).to.equal(1); cache.clear(); expect(cache.getStats().size).to.equal(0); }); }); describe('Default configuration', () => { it('should use default TTL and max entries', () => { const defaultCache = new CacheManager(); const stats = defaultCache.getStats(); expect(stats.ttl).to.equal(5 * 60 * 1000); // 5 minutes expect(stats.maxSize).to.equal(200); }); }); }); linagora-ldap-rest-16e557e/test/browser/ldap-group-editor.test.ts000066400000000000000000000076221522642357000251050ustar00rootroot00000000000000import { expect } from 'chai'; import nock from 'nock'; import { LdapGroupEditor } from '../../src/browser/ldap-group-editor/index.js'; describe('Browser LDAP Group Editor', () => { const baseUrl = 'http://localhost:8081'; const containerId = 'test-container'; let editor: LdapGroupEditor; afterEach(() => { nock.cleanAll(); }); describe('Constructor', () => { it('should create instance with group-specific options', () => { let savedGroupDn: string | undefined; editor = new LdapGroupEditor({ containerId, apiBaseUrl: baseUrl, onGroupSaved: (groupDn: string) => { savedGroupDn = groupDn; }, onError: (error: Error) => { console.error(error); }, }); expect(editor).to.be.instanceOf(LdapGroupEditor); }); it('should work without optional callbacks', () => { editor = new LdapGroupEditor({ containerId, apiBaseUrl: baseUrl, }); expect(editor).to.be.instanceOf(LdapGroupEditor); }); }); describe('API delegation', () => { beforeEach(() => { editor = new LdapGroupEditor({ containerId, apiBaseUrl: baseUrl, }); }); it('should delegate getApi to user editor', () => { // Test that getApi method exists and can be called const api = editor.getApi(); expect(api).to.exist; expect(api).to.have.property('getConfig'); }); it('should delegate getConfig to user editor', () => { // Test that getConfig method exists and can be called // Returns null initially since init() hasn't been called const config = editor.getConfig(); expect(config).to.be.null; }); }); describe('Context methods', () => { beforeEach(() => { editor = new LdapGroupEditor({ containerId, apiBaseUrl: baseUrl, }); }); it('should get current organization DN', () => { // Test without init() - should return null initially const orgDn = editor.getCurrentOrgDn(); expect(orgDn).to.be.null; }); it('should get current group DN (mapped from getCurrentUserDn)', () => { // Test without init() - should return null initially const groupDn = editor.getCurrentUserDn(); expect(groupDn).to.be.null; }); }); describe('CRUD operations', () => { beforeEach(() => { editor = new LdapGroupEditor({ containerId, apiBaseUrl: baseUrl, }); }); it('should have createUser method for creating groups', () => { // Test that the method exists and is callable expect(editor).to.have.property('createUser'); expect(editor.createUser).to.be.a('function'); }); it('should have deleteUser method for deleting groups', () => { // Test that the method exists and is callable expect(editor).to.have.property('deleteUser'); expect(editor.deleteUser).to.be.a('function'); }); }); describe('Initialization', () => { it('should have init method', () => { editor = new LdapGroupEditor({ containerId, apiBaseUrl: baseUrl, }); expect(editor).to.have.property('init'); expect(editor.init).to.be.a('function'); }); }); describe('Callback mapping', () => { it('should accept onGroupSaved callback in constructor', () => { const callback = (groupDn: string) => { // Mock callback }; editor = new LdapGroupEditor({ containerId, apiBaseUrl: baseUrl, onGroupSaved: callback, }); expect(editor).to.be.instanceOf(LdapGroupEditor); }); it('should accept onError callback in constructor', () => { const callback = (error: Error) => { // Mock callback }; editor = new LdapGroupEditor({ containerId, apiBaseUrl: baseUrl, onError: callback, }); expect(editor).to.be.instanceOf(LdapGroupEditor); }); }); }); linagora-ldap-rest-16e557e/test/browser/ldap-unit-editor.test.ts000066400000000000000000000105771522642357000247330ustar00rootroot00000000000000import { expect } from 'chai'; import nock from 'nock'; import { LdapUnitEditor } from '../../src/browser/ldap-unit-editor/index.js'; describe('Browser LDAP Unit Editor', () => { const baseUrl = 'http://localhost:8081'; const containerId = 'test-container'; let editor: LdapUnitEditor; afterEach(() => { nock.cleanAll(); }); describe('Constructor', () => { it('should create instance with unit-specific options', () => { let savedUnitDn: string | undefined; editor = new LdapUnitEditor({ containerId, apiBaseUrl: baseUrl, onUnitSaved: (unitDn: string) => { savedUnitDn = unitDn; }, onError: (error: Error) => { console.error(error); }, }); expect(editor).to.be.instanceOf(LdapUnitEditor); }); it('should work without optional callbacks', () => { editor = new LdapUnitEditor({ containerId, apiBaseUrl: baseUrl, }); expect(editor).to.be.instanceOf(LdapUnitEditor); }); }); describe('API delegation', () => { beforeEach(() => { editor = new LdapUnitEditor({ containerId, apiBaseUrl: baseUrl, }); }); it('should delegate getApi to user editor', () => { // Test that getApi method exists and can be called const api = editor.getApi(); expect(api).to.exist; expect(api).to.have.property('getConfig'); }); it('should delegate getConfig to user editor', () => { // Test that getConfig method exists and can be called // Returns null initially since init() hasn't been called const config = editor.getConfig(); expect(config).to.be.null; }); }); describe('Context methods', () => { beforeEach(() => { editor = new LdapUnitEditor({ containerId, apiBaseUrl: baseUrl, }); }); it('should get current organization DN', () => { // Test without init() - should return null initially const orgDn = editor.getCurrentOrgDn(); expect(orgDn).to.be.null; }); it('should get current unit DN (mapped from getCurrentUserDn)', () => { // Test without init() - should return null initially const unitDn = editor.getCurrentUserDn(); expect(unitDn).to.be.null; }); }); describe('CRUD operations', () => { beforeEach(() => { editor = new LdapUnitEditor({ containerId, apiBaseUrl: baseUrl, }); }); it('should have createUser method for creating units', () => { // Test that the method exists and is callable expect(editor).to.have.property('createUser'); expect(editor.createUser).to.be.a('function'); }); it('should have deleteUser method for deleting units', () => { // Test that the method exists and is callable expect(editor).to.have.property('deleteUser'); expect(editor.deleteUser).to.be.a('function'); }); }); describe('Initialization', () => { it('should have init method', () => { editor = new LdapUnitEditor({ containerId, apiBaseUrl: baseUrl, }); expect(editor).to.have.property('init'); expect(editor.init).to.be.a('function'); }); }); describe('Callback mapping', () => { it('should accept onUnitSaved callback in constructor', () => { const callback = (unitDn: string) => { // Mock callback }; editor = new LdapUnitEditor({ containerId, apiBaseUrl: baseUrl, onUnitSaved: callback, }); expect(editor).to.be.instanceOf(LdapUnitEditor); }); it('should accept onError callback in constructor', () => { const callback = (error: Error) => { // Mock callback }; editor = new LdapUnitEditor({ containerId, apiBaseUrl: baseUrl, onError: callback, }); expect(editor).to.be.instanceOf(LdapUnitEditor); }); }); describe('Organization-specific behavior', () => { it('should be designed for organization management', () => { editor = new LdapUnitEditor({ containerId, apiBaseUrl: baseUrl, }); // LdapUnitEditor is a wrapper that delegates to LdapUserEditor // but provides organization-friendly naming expect(editor).to.be.instanceOf(LdapUnitEditor); expect(editor).to.have.property('getCurrentUserDn'); expect(editor).to.have.property('getCurrentOrgDn'); }); }); }); linagora-ldap-rest-16e557e/test/browser/resource-api-client.test.ts000066400000000000000000000307601522642357000254200ustar00rootroot00000000000000import { expect } from 'chai'; import nock from 'nock'; import { ResourceApiClient } from '../../src/browser/ldap-resource-editor/api/ResourceApiClient'; const { DM_LDAP_BASE } = process.env; const LDAP_BASE = DM_LDAP_BASE || '${LDAP_BASE}'; describe('Browser Resource API Client', () => { const baseUrl = 'http://localhost:8081'; afterEach(() => { nock.cleanAll(); }); describe('Users resource type', () => { let client: ResourceApiClient; beforeEach(() => { client = new ResourceApiClient('users', baseUrl); }); it('should get resources', async () => { const mockResponse = [ { dn: 'uid=user1,ou=users,${LDAP_BASE}', uid: 'user1' }, { dn: 'uid=user2,ou=users,${LDAP_BASE}', uid: 'user2' }, ]; nock(baseUrl).get('/api/v1/ldap/users').reply(200, mockResponse); const result = await client.getResources(); expect(result).to.deep.equal(mockResponse); }); it('should get resources with search', async () => { const mockResponse = [ { dn: 'uid=john,ou=users,${LDAP_BASE}', uid: 'john' }, ]; nock(baseUrl) .get('/api/v1/ldap/users?match=john&attribute=uid') .reply(200, mockResponse); const result = await client.getResources('john'); expect(result).to.deep.equal(mockResponse); }); it('should handle object response format', async () => { const mockResponse = { user1: { dn: 'uid=user1,ou=users,${LDAP_BASE}', uid: 'user1' }, user2: { dn: 'uid=user2,ou=users,${LDAP_BASE}', uid: 'user2' }, }; nock(baseUrl).get('/api/v1/ldap/users').reply(200, mockResponse); const result = await client.getResources(); expect(result).to.be.an('array').with.lengthOf(2); expect(result[0]).to.have.property('dn'); }); it('should get single resource', async () => { const dn = 'uid=user1,ou=users,${LDAP_BASE}'; const mockResponse = { dn, uid: 'user1' }; nock(baseUrl) .get(`/api/v1/ldap/users/${encodeURIComponent(dn)}`) .reply(200, mockResponse); const result = await client.getResource(dn); expect(result).to.deep.equal(mockResponse); }); it('should update resource', async () => { const dn = 'uid=user1,ou=users,${LDAP_BASE}'; const data = { cn: 'Updated Name' }; const mockResponse = { dn, uid: 'user1', cn: 'Updated Name' }; nock(baseUrl) .put(`/api/v1/ldap/users/${encodeURIComponent(dn)}`, data) .reply(200, mockResponse); const result = await client.updateResource(dn, data); expect(result).to.deep.equal(mockResponse); }); it('should create resource', async () => { const data = { uid: 'newuser', cn: 'New User' }; const mockResponse = { dn: 'uid=newuser,ou=users,${LDAP_BASE}', ...data, }; nock(baseUrl).post('/api/v1/ldap/users', data).reply(200, mockResponse); const result = await client.createResource(data); expect(result).to.deep.equal(mockResponse); }); it('should delete resource', async () => { const dn = 'uid=user1,ou=users,${LDAP_BASE}'; nock(baseUrl) .delete(`/api/v1/ldap/users/${encodeURIComponent(dn)}`) .reply(200); await client.deleteResource(dn); }); }); describe('Groups resource type', () => { let client: ResourceApiClient; beforeEach(() => { client = new ResourceApiClient('groups', baseUrl); }); it('should use correct endpoint for groups', async () => { const mockResponse = [ { dn: 'cn=group1,ou=groups,${LDAP_BASE}', cn: 'group1' }, ]; nock(baseUrl).get('/api/v1/ldap/groups').reply(200, mockResponse); const result = await client.getResources(); expect(result).to.deep.equal(mockResponse); }); it('should use cn as main attribute', async () => { const mockResponse = [ { dn: 'cn=admin,ou=groups,${LDAP_BASE}', cn: 'admin' }, ]; nock(baseUrl) .get('/api/v1/ldap/groups?match=admin&attribute=cn') .reply(200, mockResponse); const result = await client.getResources('admin'); expect(result).to.deep.equal(mockResponse); }); }); describe('Organizations resource type', () => { let client: ResourceApiClient; beforeEach(() => { client = new ResourceApiClient('organizations', baseUrl); }); it('should use correct endpoint for organizations', async () => { const mockResponse = [ { dn: 'ou=org1,ou=organization,${LDAP_BASE}', ou: 'org1' }, ]; nock(baseUrl).get('/api/v1/ldap/organizations').reply(200, mockResponse); const result = await client.getResources(); expect(result).to.deep.equal(mockResponse); }); it('should use ou as main attribute', async () => { const mockResponse = [ { dn: 'ou=HR,ou=organization,${LDAP_BASE}', ou: 'HR' }, ]; nock(baseUrl) .get('/api/v1/ldap/organizations?match=HR&attribute=ou') .reply(200, mockResponse); const result = await client.getResources('HR'); expect(result).to.deep.equal(mockResponse); }); it('should create entry for organizations', async () => { const dn = 'ou=neworg,ou=organization,${LDAP_BASE}'; const data = { ou: 'neworg' }; const mockResponse = { dn, ...data }; nock(baseUrl) .put(`/api/v1/ldap/entry/${encodeURIComponent(dn)}`, data) .reply(200, mockResponse); const result = await client.createEntry(dn, data); expect(result).to.deep.equal(mockResponse); }); it('should delete entry for organizations', async () => { const dn = 'ou=oldorg,ou=organization,${LDAP_BASE}'; nock(baseUrl) .delete(`/api/v1/ldap/entry/${encodeURIComponent(dn)}`) .reply(200); await client.deleteEntry(dn); }); }); describe('Config and Schema', () => { let client: ResourceApiClient; beforeEach(() => { client = new ResourceApiClient('users', baseUrl); }); it('should get config', async () => { const mockConfig = { ldapBase: '${LDAP_BASE}', features: { flatResources: [] }, }; nock(baseUrl).get('/api/v1/config').reply(200, mockConfig); const result = await client.getConfig(); expect(result).to.deep.equal(mockConfig); }); it('should get schema', async () => { const schemaUrl = '/static/schemas/users.json'; const mockSchema = { entity: { name: 'user' }, attributes: {}, }; nock(baseUrl).get(schemaUrl).reply(200, mockSchema); const result = await client.getSchema(schemaUrl); expect(result).to.deep.equal(mockSchema); }); }); describe('Pointer Options', () => { let client: ResourceApiClient; beforeEach(() => { client = new ResourceApiClient('users', baseUrl); }); it('should get pointer options', async () => { const branch = 'ou=users,${LDAP_BASE}'; const mockConfig = { features: { ldapFlatGeneric: { flatResources: [ { base: branch, mainAttribute: 'uid', schemaUrl: '/static/schemas/users.json', endpoints: { list: '/api/v1/ldap/users', }, }, ], }, }, }; const mockSchema = { attributes: { cn: { role: 'displayName' }, }, }; const mockUsers = [ { dn: 'uid=user1,ou=users,${LDAP_BASE}', uid: 'user1', cn: 'User One' }, { dn: 'uid=user2,ou=users,${LDAP_BASE}', uid: 'user2', cn: 'User Two' }, ]; nock(baseUrl).get('/api/v1/config').reply(200, mockConfig); nock(baseUrl).get('/static/schemas/users.json').reply(200, mockSchema); nock(baseUrl).get('/api/v1/ldap/users').reply(200, mockUsers); const result = await client.getPointerOptions(branch); expect(result).to.be.an('array').with.lengthOf(2); expect(result[0]).to.have.property('dn'); expect(result[0]).to.have.property('label'); expect(result[0].label).to.equal('User One'); }); it('should handle missing config gracefully', async () => { const branch = 'ou=unknown,${LDAP_BASE}'; const mockConfig = { features: { ldapFlatGeneric: { flatResources: [] } }, }; nock(baseUrl).get('/api/v1/config').reply(200, mockConfig); const result = await client.getPointerOptions(branch); expect(result).to.be.an('array').that.is.empty; }); }); describe('Cache functionality', () => { let client: ResourceApiClient; beforeEach(() => { client = new ResourceApiClient('users', baseUrl); }); it('should cache GET requests', async () => { const mockResponse = [{ dn: 'uid=user1,ou=users,${LDAP_BASE}' }]; // First request nock(baseUrl).get('/api/v1/ldap/users').reply(200, mockResponse); const result1 = await client.getResources(); // Second request should use cache (nock won't match if called again) const result2 = await client.getResources(); expect(result1).to.deep.equal(result2); }); it('should not cache non-GET requests', async () => { const data = { uid: 'newuser' }; const mockResponse = { dn: 'uid=newuser,ou=users,${LDAP_BASE}', ...data }; nock(baseUrl).post('/api/v1/ldap/users', data).reply(200, mockResponse); await client.createResource(data); // Cache stats should not include POST requests const stats = client.getCacheStats(); expect(stats.keys).to.not.include('/api/v1/ldap/users'); }); it('should clear cache', async () => { const mockResponse = [{ dn: 'uid=user1,ou=users,${LDAP_BASE}' }]; nock(baseUrl).get('/api/v1/ldap/users').reply(200, mockResponse); await client.getResources(); client.clearCache(); const stats = client.getCacheStats(); expect(stats.size).to.equal(0); }); it('should invalidate cache by pattern', async () => { const mockUsers = [{ dn: 'uid=user1,ou=users,${LDAP_BASE}' }]; const mockConfig = { ldapBase: '${LDAP_BASE}' }; nock(baseUrl).get('/api/v1/ldap/users').reply(200, mockUsers); nock(baseUrl).get('/api/v1/config').reply(200, mockConfig); await client.getResources(); await client.getConfig(); client.invalidateCache('*/ldap/users*'); const stats = client.getCacheStats(); // Config should still be cached expect(stats.keys.some(k => k.includes('/config'))).to.be.true; }); }); describe('Error handling', () => { let client: ResourceApiClient; beforeEach(() => { client = new ResourceApiClient('users', baseUrl); }); it('should handle 404 errors', async () => { const dn = 'uid=notfound,ou=users,${LDAP_BASE}'; nock(baseUrl) .get(`/api/v1/ldap/users/${encodeURIComponent(dn)}`) .reply(404, 'Not Found'); try { await client.getResource(dn); expect.fail('Should have thrown error'); } catch (error) { expect(error).to.be.instanceOf(Error); expect((error as Error).message).to.include('404'); } }); it('should handle update errors', async () => { const dn = 'uid=user1,ou=users,${LDAP_BASE}'; const data = { cn: 'Updated' }; nock(baseUrl) .put(`/api/v1/ldap/users/${encodeURIComponent(dn)}`) .reply(500, 'Internal Server Error'); try { await client.updateResource(dn, data); expect.fail('Should have thrown error'); } catch (error) { expect(error).to.be.instanceOf(Error); expect((error as Error).message).to.include('Failed to update users'); } }); it('should handle create errors', async () => { const data = { uid: 'newuser' }; nock(baseUrl).post('/api/v1/ldap/users').reply(400, 'Invalid data'); try { await client.createResource(data); expect.fail('Should have thrown error'); } catch (error) { expect(error).to.be.instanceOf(Error); expect((error as Error).message).to.include('Failed to create users'); } }); it('should handle delete errors', async () => { const dn = 'uid=user1,ou=users,${LDAP_BASE}'; nock(baseUrl) .delete(`/api/v1/ldap/users/${encodeURIComponent(dn)}`) .reply(403, 'Permission denied'); try { await client.deleteResource(dn); expect.fail('Should have thrown error'); } catch (error) { expect(error).to.be.instanceOf(Error); expect((error as Error).message).to.include('Failed to delete users'); } }); }); }); linagora-ldap-rest-16e557e/test/browser/store.test.ts000066400000000000000000000107401522642357000226760ustar00rootroot00000000000000import { expect } from 'chai'; import { Store, type Action, } from '../../src/browser/ldap-tree-viewer/store/Store'; describe('Browser Store', () => { interface TestState { count: number; name: string; } const initialState: TestState = { count: 0, name: 'test', }; const reducer = (state: TestState, action: Action): TestState => { switch (action.type) { case 'INCREMENT': return { ...state, count: state.count + 1 }; case 'DECREMENT': return { ...state, count: state.count - 1 }; case 'SET_NAME': return { ...state, name: action.payload as string }; case 'RESET': return initialState; default: return state; } }; describe('Store initialization', () => { it('should initialize with initial state', () => { const store = new Store(reducer, initialState); expect(store.getState()).to.deep.equal(initialState); }); }); describe('Store dispatch', () => { it('should update state on dispatch', () => { const store = new Store(reducer, initialState); store.dispatch({ type: 'INCREMENT' }); expect(store.getState().count).to.equal(1); }); it('should handle multiple dispatches', () => { const store = new Store(reducer, initialState); store.dispatch({ type: 'INCREMENT' }); store.dispatch({ type: 'INCREMENT' }); store.dispatch({ type: 'DECREMENT' }); expect(store.getState().count).to.equal(1); }); it('should handle actions with payload', () => { const store = new Store(reducer, initialState); store.dispatch({ type: 'SET_NAME', payload: 'new name' }); expect(store.getState().name).to.equal('new name'); }); it('should return same state for unknown action', () => { const store = new Store(reducer, initialState); const stateBefore = store.getState(); store.dispatch({ type: 'UNKNOWN_ACTION' }); expect(store.getState()).to.deep.equal(stateBefore); }); }); describe('Store subscribe', () => { it('should notify subscribers on state change', () => { const store = new Store(reducer, initialState); let notified = false; store.subscribe(() => { notified = true; }); store.dispatch({ type: 'INCREMENT' }); expect(notified).to.be.true; }); it('should not notify if state does not change', () => { const store = new Store(reducer, initialState); let notifyCount = 0; store.subscribe(() => { notifyCount++; }); // Unknown action should not change state store.dispatch({ type: 'UNKNOWN_ACTION' }); expect(notifyCount).to.equal(0); }); it('should notify multiple subscribers', () => { const store = new Store(reducer, initialState); let notify1 = false; let notify2 = false; store.subscribe(() => { notify1 = true; }); store.subscribe(() => { notify2 = true; }); store.dispatch({ type: 'INCREMENT' }); expect(notify1).to.be.true; expect(notify2).to.be.true; }); it('should allow unsubscribing', () => { const store = new Store(reducer, initialState); let notifyCount = 0; const unsubscribe = store.subscribe(() => { notifyCount++; }); store.dispatch({ type: 'INCREMENT' }); expect(notifyCount).to.equal(1); unsubscribe(); store.dispatch({ type: 'INCREMENT' }); expect(notifyCount).to.equal(1); // Should not increment }); it('should handle multiple subscribers with unsubscribe', () => { const store = new Store(reducer, initialState); let notify1 = 0; let notify2 = 0; const unsub1 = store.subscribe(() => { notify1++; }); store.subscribe(() => { notify2++; }); store.dispatch({ type: 'INCREMENT' }); expect(notify1).to.equal(1); expect(notify2).to.equal(1); unsub1(); store.dispatch({ type: 'INCREMENT' }); expect(notify1).to.equal(1); // Should not increment expect(notify2).to.equal(2); // Should increment }); }); describe('Store immutability', () => { it('should not mutate previous state', () => { const store = new Store(reducer, initialState); const stateBefore = { ...store.getState() }; store.dispatch({ type: 'INCREMENT' }); // Previous state should remain unchanged expect(stateBefore.count).to.equal(0); expect(store.getState().count).to.equal(1); }); }); }); linagora-ldap-rest-16e557e/test/error-handling.test.ts000066400000000000000000000123131522642357000227700ustar00rootroot00000000000000import { expect } from 'chai'; import request from 'supertest'; import { DM } from '../src/bin/index'; describe('Error Handling', () => { let server: DM | null; before(async () => { process.env.NODE_ENV = 'test'; process.env.DM_PORT = '64323'; process.env.DM_PLUGINS = '../../test/__plugins__/error-test/index.js'; // @ts-ignore server = new DM(); await server.ready; await server.run(); }); it('should handle async errors with asyncHandler and return 500', async () => { const res = await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-handled' ); expect(res.status).to.equal(500); expect(res.body).to.have.property('error'); expect(res.body.error).to.equal('Internal Server Error'); }); it('should handle unhandled async errors via error middleware and return 500', async () => { const res = await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-unhandled' ); expect(res.status).to.equal(500); expect(res.body).to.have.property('error'); }); it('should keep server alive after errors', async () => { // Trigger an error await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-handled' ); // Server should still respond to valid requests const res = await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/ok' ); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ status: 'ok' }); }); it('should keep server alive after multiple errors', async () => { // Trigger multiple errors await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-handled' ); await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-unhandled' ); await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-handled' ); // Server should still respond to valid requests const res = await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/ok' ); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ status: 'ok' }); }); it('should return 400 for BadRequestError with error message', async () => { const res = await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-badrequest' ); expect(res.status).to.equal(400); expect(res.body).to.have.property('error'); expect(res.body.error).to.equal('Invalid request parameter'); }); it('should return 404 for NotFoundError with error message', async () => { const res = await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-notfound' ); expect(res.status).to.equal(404); expect(res.body).to.have.property('error'); expect(res.body.error).to.equal('Resource not found'); }); it('should keep server alive after client errors', async () => { // Trigger client errors await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-badrequest' ); await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-notfound' ); // Server should still respond to valid requests const res = await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/ok' ); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ status: 'ok' }); }); it('should return 502 for BadGatewayError and hide error message', async () => { const res = await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-badgateway' ); expect(res.status).to.equal(502); expect(res.body).to.have.property('error'); expect(res.body.error).to.equal('Internal Server Error'); }); it('should return 503 for ServiceUnavailableError and hide error message', async () => { const res = await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-serviceunavailable' ); expect(res.status).to.equal(503); expect(res.body).to.have.property('error'); expect(res.body.error).to.equal('Internal Server Error'); }); it('should return 504 for GatewayTimeoutError and hide error message', async () => { const res = await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-gatewaytimeout' ); expect(res.status).to.equal(504); expect(res.body).to.have.property('error'); expect(res.body.error).to.equal('Internal Server Error'); }); it('should keep server alive after server errors', async () => { // Trigger server errors await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-badgateway' ); await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-serviceunavailable' ); await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/error-gatewaytimeout' ); // Server should still respond to valid requests const res = await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/ok' ); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ status: 'ok' }); }); after(() => { delete process.env.NODE_ENV; delete process.env.DM_PORT; delete process.env.DM_PLUGINS; server?.stop(); }); }); linagora-ldap-rest-16e557e/test/fixtures/000077500000000000000000000000001522642357000204005ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/fixtures/b2b-organizations.ldif000066400000000000000000000047241522642357000246010ustar00rootroot00000000000000# B2B Organization structures for testing # This file creates sample B2B organizations with users and groups # Organization 1: Acme Corporation dn: o=acme-corp,dc=example,dc=com objectClass: organization o: acme-corp description: Acme Corporation dn: ou=users,o=acme-corp,dc=example,dc=com objectClass: organizationalUnit ou: users description: Acme Corp users dn: ou=groups,o=acme-corp,dc=example,dc=com objectClass: organizationalUnit ou: groups description: Acme Corp groups # Acme users dn: uid=alice.johnson,ou=users,o=acme-corp,dc=example,dc=com objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person uid: alice.johnson cn: Alice Johnson sn: Johnson givenName: Alice mail: alice@acme-corp.example.com userPassword: password123 displayName: Alice Johnson description: Engineer at Acme Corp dn: uid=bob.wilson,ou=users,o=acme-corp,dc=example,dc=com objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person uid: bob.wilson cn: Bob Wilson sn: Wilson givenName: Bob mail: bob@acme-corp.example.com userPassword: password123 displayName: Bob Wilson description: Manager at Acme Corp # Acme groups dn: cn=engineering,ou=groups,o=acme-corp,dc=example,dc=com objectClass: groupOfNames cn: engineering description: Engineering team member: uid=alice.johnson,ou=users,o=acme-corp,dc=example,dc=com dn: cn=management,ou=groups,o=acme-corp,dc=example,dc=com objectClass: groupOfNames cn: management description: Management team member: uid=bob.wilson,ou=users,o=acme-corp,dc=example,dc=com # Organization 2: Beta Industries dn: o=beta-industries,dc=example,dc=com objectClass: organization o: beta-industries description: Beta Industries dn: ou=users,o=beta-industries,dc=example,dc=com objectClass: organizationalUnit ou: users description: Beta Industries users dn: ou=groups,o=beta-industries,dc=example,dc=com objectClass: organizationalUnit ou: groups description: Beta Industries groups # Beta users dn: uid=charlie.brown,ou=users,o=beta-industries,dc=example,dc=com objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person uid: charlie.brown cn: Charlie Brown sn: Brown givenName: Charlie mail: charlie@beta-industries.example.com userPassword: password123 displayName: Charlie Brown description: Sales rep at Beta Industries # Beta groups dn: cn=sales,ou=groups,o=beta-industries,dc=example,dc=com objectClass: groupOfNames cn: sales description: Sales team member: uid=charlie.brown,ou=users,o=beta-industries,dc=example,dc=com linagora-ldap-rest-16e557e/test/fixtures/base-structure.ldif000066400000000000000000000127331522642357000242160ustar00rootroot00000000000000# Base LDAP structure for tests # This file is automatically loaded when starting the test LDAP server # Organizational Units dn: ou=users,dc=example,dc=com objectClass: organizationalUnit ou: users description: Users branch (B2C) dn: ou=groups,dc=example,dc=com objectClass: organizationalUnit ou: groups description: Groups branch (B2C) dn: ou=lists,ou=groups,dc=example,dc=com objectClass: organizationalUnit ou: lists description: Mailing lists branch dn: ou=teams,ou=groups,dc=example,dc=com objectClass: organizationalUnit ou: teams description: Team mailboxes branch dn: ou=applicative,dc=example,dc=com objectClass: organizationalUnit ou: applicative description: Applicative accounts branch dn: ou=calendarResources,dc=example,dc=com objectClass: organizationalUnit ou: calendarResources description: Calendar resources branch dn: ou=trash,dc=example,dc=com objectClass: organizationalUnit ou: trash description: Trash bin for deleted entries dn: ou=external,dc=example,dc=com objectClass: organizationalUnit ou: external description: External members branch for groups dn: ou=nomenclature,dc=example,dc=com objectClass: organizationalUnit ou: nomenclature description: Nomenclature data dn: ou=twakeTitle,ou=nomenclature,dc=example,dc=com objectClass: organizationalUnit ou: twakeTitle description: Title nomenclature dn: ou=twakeListType,ou=nomenclature,dc=example,dc=com objectClass: organizationalUnit ou: twakeListType description: List type nomenclature dn: ou=twakeMailboxType,ou=nomenclature,dc=example,dc=com objectClass: organizationalUnit ou: twakeMailboxType description: Mailbox type nomenclature # Standard nomenclature entries dn: cn=Dr,ou=twakeTitle,ou=nomenclature,dc=example,dc=com objectClass: device cn: Dr description: Doctor dn: cn=Mr,ou=twakeTitle,ou=nomenclature,dc=example,dc=com objectClass: device cn: Mr description: Mister dn: cn=Ms,ou=twakeTitle,ou=nomenclature,dc=example,dc=com objectClass: device cn: Ms description: Miss dn: cn=openList,ou=twakeListType,ou=nomenclature,dc=example,dc=com objectClass: device cn: openList description: Open mailing list dn: cn=memberRestrictedList,ou=twakeListType,ou=nomenclature,dc=example,dc=com objectClass: device cn: memberRestrictedList description: Member-restricted mailing list dn: cn=group,ou=twakeMailboxType,ou=nomenclature,dc=example,dc=com objectClass: device cn: group description: Simple group without mailbox dn: cn=mailingList,ou=twakeMailboxType,ou=nomenclature,dc=example,dc=com objectClass: device cn: mailingList description: Mailing list (address group) dn: cn=teamMailbox,ou=twakeMailboxType,ou=nomenclature,dc=example,dc=com objectClass: device cn: teamMailbox description: Team mailbox (shared mailbox) # Test users (B2C) dn: uid=john.doe,ou=users,dc=example,dc=com objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person uid: john.doe cn: John Doe sn: Doe givenName: John mail: john.doe@example.com userPassword: password123 displayName: John Doe description: Test user for B2C dn: uid=jane.smith,ou=users,dc=example,dc=com objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person uid: jane.smith cn: Jane Smith sn: Smith givenName: Jane mail: jane.smith@example.com userPassword: password123 displayName: Jane Smith description: Test user for B2C dn: uid=peter.jones,ou=users,dc=example,dc=com objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person uid: peter.jones cn: Peter Jones sn: Jones givenName: Peter mail: peter.jones@example.com userPassword: password123 displayName: Peter Jones description: Test user for search tests # Test group (B2C) dn: cn=admins,ou=groups,dc=example,dc=com objectClass: groupOfNames cn: admins description: Administrators group member: uid=john.doe,ou=users,dc=example,dc=com member: uid=jane.smith,ou=users,dc=example,dc=com # Organization structure (B2B) dn: ou=organization,dc=example,dc=com objectClass: organizationalUnit objectClass: top ou: organization description: Top organization for B2B # Test organizations with twakeDepartment auxiliary class dn: ou=Test Org 1,ou=organization,dc=example,dc=com objectClass: organizationalUnit objectClass: twakeDepartment ou: Test Org 1 description: Test organization 1 twakeLocalAdminLink: uid=alice.admin,ou=users,dc=example,dc=com dn: ou=Test Org 2,ou=organization,dc=example,dc=com objectClass: organizationalUnit objectClass: twakeDepartment ou: Test Org 2 description: Test organization 2 dn: ou=Sub Org 1,ou=Test Org 1,ou=organization,dc=example,dc=com objectClass: organizationalUnit objectClass: twakeDepartment ou: Sub Org 1 description: Sub-organization under Test Org 1 # Test user with organization link (B2B) dn: uid=alice.admin,ou=users,dc=example,dc=com objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person objectClass: twakeWhitePages uid: alice.admin cn: Alice Admin sn: Admin givenName: Alice mail: alice.admin@example.com userPassword: password123 displayName: Alice Admin description: Test user with admin role twakeDepartmentLink: ou=Test Org 1,ou=organization,dc=example,dc=com twakeDepartmentPath: Test Org 1 dn: uid=bob.user,ou=users,dc=example,dc=com objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person objectClass: twakeWhitePages uid: bob.user cn: Bob User sn: User givenName: Bob mail: bob.user@example.com userPassword: password123 displayName: Bob User description: Test user in organization twakeDepartmentLink: ou=Sub Org 1,ou=Test Org 1,ou=organization,dc=example,dc=com twakeDepartmentPath: Test Org 1/Sub Org 1 linagora-ldap-rest-16e557e/test/fixtures/calendar-resources-schema.json000066400000000000000000000010631522642357000263120ustar00rootroot00000000000000{ "entity": { "name": "calendarResource", "mainAttribute": "cn", "objectClass": ["top", "device"], "singularName": "resource", "pluralName": "resources", "base": "ou=resources,__ldap_base__" }, "strict": false, "attributes": { "objectClass": { "type": "array", "items": { "type": "string" }, "default": ["top", "device"], "required": true, "fixed": true }, "cn": { "type": "string", "required": true }, "description": { "type": "string" } } } linagora-ldap-rest-16e557e/test/fixtures/mail-schema.ldif000066400000000000000000000017031522642357000234210ustar00rootroot00000000000000# Additional mail attributes schema # Defines mail-related attributes needed by Twake # Note: mailForwardingAddress is already defined in inetmaildomain schema dn: cn=mail,cn=schema,cn=config objectClass: olcSchemaConfig cn: mail olcAttributeTypes: {0}( 1.3.6.1.4.1.99999.2.1.1 NAME 'mailAlternateAddress' DESC 'Alternate email address' EQUALITY caseIgnoreIA5Match SUBSTR caseIgnoreIA5SubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) olcAttributeTypes: {1}( 1.3.6.1.4.1.99999.2.1.2 NAME 'mailHost' DESC 'Mail server host' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE ) olcAttributeTypes: {2}( 1.3.6.1.4.1.99999.2.1.3 NAME 'mailQuotaSize' DESC 'Mail quota size in bytes' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) olcAttributeTypes: {3}( 1.3.6.1.4.1.99999.2.1.5 NAME 'mailReplyText' DESC 'Mail auto-reply text' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE ) linagora-ldap-rest-16e557e/test/fixtures/twake-schema.ldif000066400000000000000000000123331522642357000236130ustar00rootroot00000000000000dn: cn=twake,cn=schema,cn=config objectClass: olcSchemaConfig cn: twake olcObjectIdentifier: {0}TwakeOID 1.3.6.1.4.1.10943.1.50 olcObjectIdentifier: {1}TwakeAttributType TwakeOID:1 olcObjectIdentifier: {2}TwakeObjectClass TwakeOID:2 olcAttributeTypes: {0}( 2.16.840.1.101.2.2.1.133 NAME 'rank' DESC 'Grade du personnel' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE ) olcAttributeTypes: {1}( TwakeAttributType:0 NAME 'twakeOtherMailbox' DESC 'Recovery mailaddress for the same user' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE) olcAttributeTypes: {2} ( TwakeAttributType:1 NAME 'twakeDepartmentLink' DESC 'Department Link (of the object)' SUP distinguishedName ) olcAttributeTypes: {3} ( TwakeAttributType:2 NAME 'twakeDepartmentPath' DESC 'Department Path (of the object)' SUP name ) olcAttributeTypes: {4} ( TwakeAttributType:3 NAME 'twakeDomainLink' DESC 'Domain Link (of the object)' SUP distinguishedName ) olcAttributeTypes: {5} ( TwakeAttributType:4 NAME 'twakeBALFLink' DESC 'BALF Link (of the object)' SUP distinguishedName ) olcAttributeTypes: {6} ( TwakeAttributType:5 NAME 'twakeGroupLink' DESC 'Group Link (of the object)' SUP distinguishedName ) olcAttributeTypes: {7} ( TwakeAttributType:6 NAME 'twakeManagerLink' DESC 'Manager Link (of the object)' SUP distinguishedName ) olcAttributeTypes: {8} ( TwakeAttributType:7 NAME 'twakeLocalAdminLink' DESC 'Local Administrator Link (of the object)' SUP distinguishedName ) olcAttributeTypes: {9} ( TwakeAttributType:8 NAME 'twakeAccountStatus' DESC 'Status of Twake account' SUP distinguishedName ) olcAttributeTypes: {10} ( TwakeAttributType:9 NAME 'twakeDeliveryMode' DESC 'Delivery mode for Twake account' SUP distinguishedName ) olcAttributeTypes: {11} ( TwakeAttributType:10 NAME 'twakeListType' DESC 'Type of Twake list' SUP distinguishedName ) olcAttributeTypes: {12} ( TwakeAttributType:11 NAME 'twakeSynchroStatus' DESC 'Error code for Twake LDAP Sync Engine' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE ) olcAttributeTypes: {13} ( TwakeAttributType:12 NAME 'twakeDeletionDate' DESC 'Date for complete account deletion: account and data' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE ) olcAttributeTypes: {14} ( TwakeAttributType:13 NAME 'twakeDelegatedUsers' DESC 'Email addresses of users authorized to act on behalf of this user' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) olcAttributeTypes: {15} ( TwakeAttributType:14 NAME 'twakeMailboxType' DESC 'Type of mailbox for groups' SUP distinguishedName SINGLE-VALUE ) olcAttributeTypes: {16} ( TwakeAttributType:15 NAME 'twakeCozyDomain' DESC 'Cozy instance domain for the user (e.g., user.mycozy.cloud)' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE ) olcAttributeTypes: {17} ( TwakeAttributType:16 NAME 'twakeDriveQuota' DESC 'Twake Drive disk quota in bytes' EQUALITY integerMatch ORDERING integerOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) olcObjectClasses: {0}( TwakeObjectClass:0 NAME 'twakeAccount' DESC 'ObjectClass for Twake account' SUP top STRUCTURAL MUST uid MAY ( employeeNumber $ userpassword $ mail $ mailHost $ mailAlternateAddress $ mailHost $ mailQuotaSize $ twakeDeliveryMode $ mailForwardingAddress $ mailReplyText $ twakeAccountStatus $ twakeOtherMailbox $ twakeDeletionDate $ twakeSynchroStatus $ description $ twakeDelegatedUsers $ twakeCozyDomain $ twakeDriveQuota ) ) olcObjectClasses: {1}( TwakeObjectClass:1 NAME 'twakeWhitePages' DESC 'ObjectClass for Twake account' SUP top AUXILIARY MAY ( sn $ givenname $ cn $ displayName $ personalTitle $ title $ rank $ postalAddress $ postalCode $ l $ mobile $ telephoneNumber $ facsimileTelephoneNumber $ departmentNumber $ twakeDepartmentLink $ TwakeDepartmentPath $ twakeCozyDomain $ twakeDriveQuota ) ) olcObjectClasses: {2}( TwakeObjectClass:2 NAME 'twakeApplication' DESC 'Twake: application' SUP organizationalUnit STRUCTURAL MAY ( owner $ twakeDepartmentLink $ twakeDepartmentPath ) ) olcObjectClasses: {3}( TwakeObjectClass:3 NAME 'twakeRole' DESC 'Twake: role' SUP groupOfNames STRUCTURAL ) olcObjectClasses: {4}( TwakeObjectClass:4 NAME 'twakeDepartment' DESC 'ObjectClass for Twake Department' SUP top AUXILIARY MAY ( twakeManagerLink $ twakeDomainLink $ twakeBALFLink $ twakeGroupLink $ twakeLocalAdminLink $ twakeDepartmentPath ) ) olcObjectClasses: {5}( TwakeObjectClass:5 NAME 'twakeGroup' DESC 'Twake group extensions (AUXILIARY)' SUP top AUXILIARY MAY ( mail $ twakeMailboxType $ twakeDepartmentLink $ twakeDepartmentPath $ twakeListType ) ) olcObjectClasses: {6}( TwakeObjectClass:6 NAME 'twakeStaticGroup' DESC 'Twake: static group' SUP groupOfNames STRUCTURAL MAY ( twakeDepartmentLink $ twakeDepartmentPath $ twakeListType $ mail $ twakeMailboxType ) ) olcObjectClasses: {7}( TwakeObjectClass:7 NAME 'twakePosition' DESC 'ObjectClass for Twake account position' SUP top STRUCTURAL MUST cn ) #olcObjectClasses: {7}( TwakeObjectClass:7 NAME 'twakeBALF' DESC 'Twake: functional mailbox' SUP account STRUCTURAL MAY ( userPassword $ mail $ twakeDepartmentLink $ twakeDepartmentPath $ owner ) ) linagora-ldap-rest-16e557e/test/helpers/000077500000000000000000000000001522642357000201715ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/helpers/env.ts000066400000000000000000000035311522642357000213330ustar00rootroot00000000000000/** * Test helper utilities for environment variable checks * @module test/helpers/env */ /** * Check if required LDAP environment variables are set * If not, skip the test with a warning message * * @param context - Mocha test context (this) * @param vars - Array of required environment variable names * @returns true if all vars are set, false otherwise */ export function skipIfMissingEnvVars( context: Mocha.Context, vars: string[] ): boolean { const missing = vars.filter(v => !process.env[v]); if (missing.length > 0) { console.warn( `Skipping test: Required env vars not set: ${missing.join(', ')}` ); context.skip(); return false; } return true; } /** * Common LDAP environment variables */ export const LDAP_ENV_VARS = [ 'DM_LDAP_DN', 'DM_LDAP_PWD', 'DM_LDAP_BASE', ] as const; /** * LDAP environment variables including top organization */ export const LDAP_ENV_VARS_WITH_ORG = [ ...LDAP_ENV_VARS, 'DM_LDAP_TOP_ORGANIZATION', ] as const; /** * James environment variables */ export const JAMES_ENV_VARS = [ 'DM_JAMES_WEBADMIN_URL', 'DM_JAMES_WEBADMIN_TOKEN', ] as const; /** * Combined LDAP and James environment variables */ export const LDAP_AND_JAMES_ENV_VARS = [ ...LDAP_ENV_VARS, ...JAMES_ENV_VARS, ] as const; /** * Drive/Cozy environment variables */ export const DRIVE_ENV_VARS = ['DM_TWAKE_DRIVE_WEBADMIN_URL'] as const; /** * Combined LDAP and Drive environment variables */ export const LDAP_AND_DRIVE_ENV_VARS = [ ...LDAP_ENV_VARS, ...DRIVE_ENV_VARS, ] as const; /** * Check if external LDAP is configured (i.e., all required env vars are set) * Used to determine if we should use external LDAP or start embedded LDAP * @returns true if external LDAP is configured */ export function hasExternalLdap(): boolean { return LDAP_ENV_VARS.every(v => !!process.env[v]); } linagora-ldap-rest-16e557e/test/helpers/globalSetup.ts000066400000000000000000000023151522642357000230230ustar00rootroot00000000000000/** * Mocha global setup * Starts LDAP server before all tests */ import { LdapTestServer, getGlobalTestLdapServer } from './ldapServer'; let globalServer: LdapTestServer | null = null; export async function mochaGlobalSetup() { console.log('\n🚀 Setting up global test environment...\n'); try { // Start LDAP server globalServer = await getGlobalTestLdapServer(); // Set environment variables for all tests const envVars = globalServer.getEnvVars(); Object.entries(envVars).forEach(([key, value]) => { process.env[key] = value; }); // Also set test organization for org-related tests process.env.DM_LDAP_TOP_ORGANIZATION = 'dc=example,dc=com'; // Set James mock URL (tests will use nock) process.env.DM_JAMES_WEBADMIN_URL = process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000'; process.env.DM_JAMES_WEBADMIN_TOKEN = process.env.DM_JAMES_WEBADMIN_TOKEN || 'test-token'; console.log('✓ LDAP server ready'); console.log(` URL: ${envVars.DM_LDAP_URI}`); console.log(` Base DN: ${envVars.DM_LDAP_BASE}`); console.log(''); } catch (err) { console.error('❌ Failed to setup test environment:', err); throw err; } } linagora-ldap-rest-16e557e/test/helpers/globalTeardown.ts000066400000000000000000000006511522642357000235070ustar00rootroot00000000000000/** * Mocha global teardown * Stops LDAP server after all tests */ import { stopGlobalTestLdapServer } from './ldapServer'; export async function mochaGlobalTeardown() { console.log('\n🧹 Cleaning up global test environment...\n'); try { await stopGlobalTestLdapServer(); console.log('✓ LDAP server stopped\n'); } catch (err) { console.error('❌ Failed to cleanup test environment:', err); } } linagora-ldap-rest-16e557e/test/helpers/ldapServer.ts000066400000000000000000000344701522642357000226600ustar00rootroot00000000000000/** * Test helper to start/stop an embedded OpenLDAP server in Docker * @module test/helpers/ldapServer */ import { execSync, spawn, ChildProcess } from 'child_process'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; export interface LdapServerConfig { /** LDAP base DN */ baseDn: string; /** Admin DN */ adminDn: string; /** Admin password */ adminPassword: string; /** LDAP port (default: random) */ port?: number; /** Docker container name */ containerName?: string; /** Initial LDIF file to load */ ldifFile?: string; /** Organization name */ organization?: string; /** Domain components (e.g., ['example', 'com']) */ domainComponents?: string[]; } export class LdapTestServer { private containerName: string; private config: Required; private containerProcess?: ChildProcess; public port: number; constructor(config: LdapServerConfig) { this.containerName = config.containerName || `ldap-test-${Date.now()}`; this.port = config.port || this.getRandomPort(); // Set defaults this.config = { baseDn: config.baseDn, adminDn: config.adminDn, adminPassword: config.adminPassword, port: this.port, containerName: this.containerName, ldifFile: config.ldifFile || '', organization: config.organization || 'Test Organization', domainComponents: config.domainComponents || ['example', 'com'], }; } /** * Get a random available port */ private getRandomPort(): number { // Use a port in the ephemeral range return 30000 + Math.floor(Math.random() * 10000); } /** * Start the LDAP server */ async start(): Promise { console.log(`Starting LDAP test server on port ${this.port}...`); // Check if Docker is available try { execSync('docker --version', { stdio: 'ignore' }); } catch (err) { throw new Error( 'Docker is not available. Please install Docker to run LDAP tests.' ); } // Stop any existing container with the same name try { execSync(`docker rm -f ${this.containerName}`, { stdio: 'ignore' }); } catch (err) { // Container doesn't exist, that's fine } // Build environment variables const env = [ `-e LDAP_ORGANISATION="${this.config.organization}"`, `-e LDAP_DOMAIN="${this.config.domainComponents.join('.')}"`, `-e LDAP_ADMIN_PASSWORD="${this.config.adminPassword}"`, `-e LDAP_CONFIG_PASSWORD="${this.config.adminPassword}"`, `-e LDAP_READONLY_USER=false`, `-e LDAP_RFC2307BIS_SCHEMA=true`, `-e LDAP_BACKEND=mdb`, `-e LDAP_TLS=false`, `-e LDAP_REMOVE_CONFIG_AFTER_SETUP=true`, ].join(' '); // Start container (without volume mount to avoid permission issues) const cmd = `docker run -d --name ${this.containerName} \ -p ${this.port}:389 \ ${env} \ osixia/openldap:1.5.0`; try { execSync(cmd, { stdio: 'pipe' }); } catch (err) { throw new Error( `Failed to start LDAP container: ${(err as Error).message}` ); } // Wait for LDAP to be ready await this.waitForReady(); // Wait for ldapi:/// socket to be available (needed for schema loading) await this.waitForLdapi(); console.log( `✓ LDAP test server started on port ${this.port} (container: ${this.containerName})` ); // Load mail schema first (contains mailAlternateAddress, mailForwardingAddress, etc.) const mailSchemaPath = join(__dirname, '../fixtures/mail-schema.ldif'); if (existsSync(mailSchemaPath)) { console.log(`Loading mail schema from ${mailSchemaPath}...`); try { execSync( `docker cp ${mailSchemaPath} ${this.containerName}:/tmp/mail-schema.ldif` ); const result = execSync( `docker exec ${this.containerName} ldapadd -Y EXTERNAL -H ldapi:/// -f /tmp/mail-schema.ldif`, { encoding: 'utf-8' } ); console.log(`✓ Mail schema loaded`); if (result && result.trim()) { console.log(`Mail schema output: ${result.trim()}`); } // Wait for schema to be fully loaded await new Promise(resolve => setTimeout(resolve, 2000)); } catch (err: any) { console.error(`ERROR loading mail schema:`); console.error(` stdout: ${err.stdout?.toString()}`); console.error(` stderr: ${err.stderr?.toString()}`); console.error(` message: ${err.message}`); throw new Error( `Failed to load mail schema: ${err.stderr?.toString() || err.message}` ); } } // Load dyngroup schema (required for groupOfURLs objectClass used by twakeDynamicGroup) try { console.log(`Loading dyngroup schema...`); const result = execSync( `docker exec ${this.containerName} ldapadd -Y EXTERNAL -H ldapi:/// -f /etc/ldap/schema/dyngroup.ldif`, { encoding: 'utf-8' } ); console.log(`✓ Dyngroup schema loaded`); if (result && result.trim()) { console.log(`Dyngroup schema output: ${result.trim()}`); } // Wait for schema to be fully loaded await new Promise(resolve => setTimeout(resolve, 2000)); } catch (err: any) { console.error(`ERROR loading dyngroup schema:`); console.error(` stdout: ${err.stdout?.toString()}`); console.error(` stderr: ${err.stderr?.toString()}`); console.error(` message: ${err.message}`); throw new Error( `Failed to load dyngroup schema: ${err.stderr?.toString() || err.message}` ); } // Load Twake custom schema // Try to use the official schema from the container first try { console.log(`Attempting to load official Twake schema...`); const result = execSync( `docker exec ${this.containerName} sh -c "if [ -f /usr/local/openldap/etc/openldap/schema/twake.ldif ]; then ldapadd -Y EXTERNAL -H ldapi:/// -f /usr/local/openldap/etc/openldap/schema/twake.ldif; else exit 1; fi"`, { encoding: 'utf-8' } ); console.log(`✓ Official Twake schema loaded from container`); if (result && result.trim()) { console.log(`Schema load output: ${result.trim()}`); } // Wait for schema to be fully loaded await new Promise(resolve => setTimeout(resolve, 2000)); } catch (err: any) { // Fallback to custom schema if official one doesn't exist const schemaPath = join(__dirname, '../fixtures/twake-schema.ldif'); if (existsSync(schemaPath)) { console.log(`Loading custom Twake schema from ${schemaPath}...`); try { // Copy schema to container execSync( `docker cp ${schemaPath} ${this.containerName}:/tmp/twake-schema.ldif` ); // Load schema using ldapadd const result = execSync( `docker exec ${this.containerName} ldapadd -Y EXTERNAL -H ldapi:/// -f /tmp/twake-schema.ldif`, { encoding: 'utf-8' } ); console.log(`✓ Custom Twake schema loaded`); if (result && result.trim()) { console.log(`Schema load output: ${result.trim()}`); } // Wait for schema to be fully loaded await new Promise(resolve => setTimeout(resolve, 2000)); } catch (err: any) { console.error(`ERROR loading Twake schema:`); console.error(` stdout: ${err.stdout?.toString()}`); console.error(` stderr: ${err.stderr?.toString()}`); console.error(` message: ${err.message}`); throw new Error( `Failed to load Twake schema: ${err.stderr?.toString() || err.message}` ); } } } // Load initial LDIF file if provided if (this.config.ldifFile && existsSync(this.config.ldifFile)) { console.log(`Loading initial LDIF from ${this.config.ldifFile}...`); const ldifContent = readFileSync(this.config.ldifFile, 'utf-8'); await this.loadLdif(ldifContent); console.log(`✓ Initial LDIF loaded`); } } /** * Wait for LDAP to accept connections */ private async waitForReady(maxRetries = 30): Promise { for (let i = 0; i < maxRetries; i++) { try { // Try to connect with ldapsearch execSync( `docker exec ${this.containerName} ldapsearch -x -H ldap://localhost -b "${this.config.baseDn}" -D "${this.config.adminDn}" -w "${this.config.adminPassword}" -LLL "(objectClass=*)" dn`, { stdio: 'ignore', timeout: 2000 } ); return; // Success } catch (err) { // Not ready yet, wait await new Promise(resolve => setTimeout(resolve, 1000)); } } throw new Error( `LDAP server did not become ready after ${maxRetries} attempts` ); } /** * Wait for ldapi:/// socket to be available */ private async waitForLdapi(maxRetries = 30): Promise { for (let i = 0; i < maxRetries; i++) { try { // Try to connect with ldapwhoami using EXTERNAL mechanism execSync( `docker exec ${this.containerName} ldapwhoami -Y EXTERNAL -H ldapi:///`, { stdio: 'ignore', timeout: 2000 } ); return; // Success } catch (err) { // Not ready yet, wait await new Promise(resolve => setTimeout(resolve, 1000)); } } throw new Error( `LDAP ldapi:/// socket did not become available after ${maxRetries} attempts` ); } /** * Stop the LDAP server */ async stop(): Promise { if (!this.containerName) return; console.log(`Stopping LDAP test server (${this.containerName})...`); try { execSync(`docker rm -f ${this.containerName}`, { stdio: 'ignore' }); console.log(`✓ LDAP test server stopped`); } catch (err) { console.warn(`Warning: Failed to stop LDAP container: ${err}`); } } /** * Load LDIF data into the server */ async loadLdif(ldifContent: string, maxRetries = 10): Promise { // Write LDIF to temporary file in container const tempFile = `/tmp/load-${Date.now()}.ldif`; try { // Write LDIF content to container const proc = spawn('docker', [ 'exec', '-i', this.containerName, 'bash', '-c', `cat > ${tempFile}`, ]); proc.stdin.write(ldifContent); proc.stdin.end(); await new Promise((resolve, reject) => { proc.on('exit', code => { if (code === 0) resolve(undefined); else reject(new Error(`Failed to write LDIF to container`)); }); }); // Load LDIF using ldapadd with retry logic let lastError: Error | undefined; for (let i = 0; i < maxRetries; i++) { try { execSync( `docker exec ${this.containerName} ldapadd -x -D "${this.config.adminDn}" -w "${this.config.adminPassword}" -f ${tempFile}`, { stdio: 'pipe', timeout: 5000 } ); // Success - clean up temp file execSync(`docker exec ${this.containerName} rm ${tempFile}`, { stdio: 'ignore', }); return; } catch (err) { lastError = err as Error; // Wait before retry await new Promise(resolve => setTimeout(resolve, 1000)); } } // All retries failed throw new Error( `Failed to load LDIF after ${maxRetries} attempts: ${lastError?.message}` ); } catch (err) { throw new Error(`Failed to load LDIF: ${(err as Error).message}`); } } /** * Execute an LDAP search command */ async search(filter: string, attrs?: string[]): Promise { const attrsStr = attrs ? attrs.join(' ') : '*'; const cmd = `docker exec ${this.containerName} ldapsearch -x -H ldap://localhost -b "${this.config.baseDn}" -D "${this.config.adminDn}" -w "${this.config.adminPassword}" -LLL "${filter}" ${attrsStr}`; try { return execSync(cmd, { encoding: 'utf-8' }); } catch (err) { throw new Error(`LDAP search failed: ${(err as Error).message}`); } } /** * Get connection configuration for tests */ getConfig(): { url: string; bindDN: string; bindPassword: string; baseDN: string; } { return { url: `ldap://localhost:${this.port}`, bindDN: this.config.adminDn, bindPassword: this.config.adminPassword, baseDN: this.config.baseDn, }; } /** * Get environment variables for DM server */ getEnvVars(): Record { return { // DM uses these env vars (with DM_ prefix) DM_LDAP_URL: `ldap://localhost:${this.port}`, DM_LDAP_DN: this.config.adminDn, DM_LDAP_PWD: this.config.adminPassword, DM_LDAP_BASE: this.config.baseDn, DM_LDAP_GROUP_BASE: `ou=groups,${this.config.baseDn}`, // External members branch for groups DM_EXTERNAL_MEMBERS_BRANCH: `ou=external,${this.config.baseDn}`, // Applicative accounts branch DM_APPLICATIVE_ACCOUNT_BASE: `ou=applicative,${this.config.baseDn}`, // Calendar resources branch DM_CALENDAR_RESOURCES_BASE: `ou=calendarResources,${this.config.baseDn}`, // Trash branch DM_TRASH_BRANCH: `ou=trash,${this.config.baseDn}`, // Keep legacy names for hasExternalLdap() check DM_LDAP_URI: `ldap://localhost:${this.port}`, }; } } /** * Create a test LDAP server with standard configuration */ export async function createTestLdapServer( ldifFile?: string ): Promise { const server = new LdapTestServer({ baseDn: 'dc=example,dc=com', adminDn: 'cn=admin,dc=example,dc=com', adminPassword: 'adminpassword', organization: 'Example Inc', domainComponents: ['example', 'com'], ldifFile, }); await server.start(); return server; } /** * Singleton test server for reuse across test suites */ let globalTestServer: LdapTestServer | null = null; /** * Get or create a global test LDAP server * Reuses the same server across test suites for performance */ export async function getGlobalTestLdapServer(): Promise { if (!globalTestServer) { globalTestServer = await createTestLdapServer( join(__dirname, '../fixtures/base-structure.ldif') ); } return globalTestServer; } /** * Stop the global test LDAP server * Call this in global test teardown */ export async function stopGlobalTestLdapServer(): Promise { if (globalTestServer) { await globalTestServer.stop(); globalTestServer = null; } } linagora-ldap-rest-16e557e/test/helpers/testSetup.ts000066400000000000000000000041631522642357000225450ustar00rootroot00000000000000/** * Global test setup * Manages a single LDAP server instance for all tests * @module test/helpers/testSetup */ import { LdapTestServer, getGlobalTestLdapServer, stopGlobalTestLdapServer, } from './ldapServer'; let globalServer: LdapTestServer | null = null; /** * Setup global test environment * Called once before all tests */ export default async function mochaGlobalSetup() { console.log('\n🚀 Setting up global test environment...\n'); try { // Start LDAP server globalServer = await getGlobalTestLdapServer(); // Set environment variables for all tests const envVars = globalServer.getEnvVars(); Object.entries(envVars).forEach(([key, value]) => { process.env[key] = value; }); // Also set test organization for org-related tests process.env.DM_LDAP_TOP_ORGANIZATION = 'dc=example,dc=com'; // Set James mock URL (tests will use nock) process.env.DM_JAMES_WEBADMIN_URL = process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000'; process.env.DM_JAMES_WEBADMIN_TOKEN = process.env.DM_JAMES_WEBADMIN_TOKEN || 'test-token'; console.log('✓ LDAP server ready'); console.log(` URL: ${envVars.DM_LDAP_URI}`); console.log(` Base DN: ${envVars.DM_LDAP_BASE}`); console.log(''); } catch (err) { console.error('❌ Failed to setup test environment:', err); throw err; } } /** * Teardown global test environment * Called once after all tests */ export async function mochaGlobalTeardown() { console.log('\n🧹 Cleaning up global test environment...\n'); try { await stopGlobalTestLdapServer(); console.log('✓ LDAP server stopped\n'); } catch (err) { console.error('❌ Failed to cleanup test environment:', err); } } /** * Get the global LDAP server instance * Use this in tests that need direct access to the server */ export function getTestLdapServer(): LdapTestServer { // Import from setup.ts which holds the actual reference const { globalServer } = require('../setup'); if (!globalServer) { throw new Error('Global LDAP server not initialized. Did you run setup?'); } return globalServer; } linagora-ldap-rest-16e557e/test/integration/000077500000000000000000000000001522642357000210525ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/integration/ldapServer.test.ts000066400000000000000000000063331522642357000245140ustar00rootroot00000000000000/** * Integration test for embedded LDAP server * Validates that the test LDAP server works correctly */ import { expect } from 'chai'; import { getTestLdapServer } from '../helpers/testSetup'; import { hasExternalLdap } from '../helpers/env'; describe('LDAP Test Server Integration', function () { // These tests validate the test infrastructure itself // Skip all tests if using external LDAP before(function () { if (hasExternalLdap()) { console.warn( 'Skipping LDAP Test Server Integration: using external LDAP' ); this.skip(); } }); it('should have started the LDAP server', function () { const server = getTestLdapServer(); expect(server).to.exist; expect(server.port).to.be.a('number'); expect(server.port).to.be.greaterThan(0); }); it('should have set environment variables', function () { expect(process.env.DM_LDAP_URI).to.exist; expect(process.env.DM_LDAP_DN).to.exist; expect(process.env.DM_LDAP_PWD).to.exist; expect(process.env.DM_LDAP_BASE).to.equal('dc=example,dc=com'); }); it('should have loaded base structure', async function () { const server = getTestLdapServer(); // Search for users branch const result = await server.search('(ou=users)', ['ou']); expect(result).to.include('ou=users'); }); it('should have test users in B2C branch', async function () { const server = getTestLdapServer(); // Search for john.doe const result = await server.search('(uid=john.doe)', ['cn', 'mail']); expect(result).to.include('uid=john.doe'); expect(result).to.include('cn: John Doe'); expect(result).to.include('mail: john.doe@example.com'); }); it('should be able to load additional LDIF data', async function () { const server = getTestLdapServer(); // Load B2B organizations const ldif = ` dn: uid=test-dynamic,ou=users,dc=example,dc=com objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person uid: test-dynamic cn: Test Dynamic sn: Dynamic mail: test-dynamic@example.com userPassword: password123 `; await server.loadLdif(ldif); // Verify it was loaded const result = await server.search('(uid=test-dynamic)', ['cn']); expect(result).to.include('uid=test-dynamic'); expect(result).to.include('cn: Test Dynamic'); // Cleanup (optional - server is torn down after all tests anyway) try { await server.search('(uid=test-dynamic)', ['cn']); // If we want to clean up, we'd need to add a delete method // For now, the global teardown handles cleanup } catch (err) { // Ignore } }); it('should have nomenclature data', async function () { const server = getTestLdapServer(); // Check for title nomenclature const result = await server.search('(cn=Dr)', ['cn', 'description']); expect(result).to.include('cn=Dr'); expect(result).to.include('description: Doctor'); }); it('should have test groups', async function () { const server = getTestLdapServer(); // Check for admins group const result = await server.search('(cn=admins)', ['cn', 'member']); expect(result).to.include('cn=admins'); expect(result).to.include( 'member: uid=john.doe,ou=users,dc=example,dc=com' ); }); }); linagora-ldap-rest-16e557e/test/lib/000077500000000000000000000000001522642357000172755ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/lib/ldapActions.test.ts000066400000000000000000000143451522642357000230730ustar00rootroot00000000000000import LdapActions from '../../src/lib/ldapActions'; import { expect } from 'chai'; import { Client, SearchResult } from 'ldapts'; import { parseConfig } from '../../src/lib/parseConfig'; import configTemplate from '../../src/config/args'; import { DM } from '../../src/bin'; import { skipIfMissingEnvVars, LDAP_ENV_VARS } from '../helpers/env'; let ldapActions: LdapActions; describe('ldapActions', function () { before(function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS]); }); beforeEach(() => { ldapActions = new LdapActions(new DM()); }); describe('search', () => { it('should perform a search and return results', async () => { const options = { filter: '(uid=p*)', paged: false, }; let result = await ldapActions.search(options); if (!(result as SearchResult).searchEntries) { const tmp = await (result as AsyncGenerator).next(); result = tmp.value; } expect(result).to.have.property('searchEntries'); expect((result as SearchResult).searchEntries).to.be.an('array'); expect((result as SearchResult).searchEntries.length).to.be.greaterThan( 0 ); }); }); describe('Modify entries', () => { let testDN: string; let newDN: string; before(function () { // Skip tests if env vars are not set if (!process.env.DM_LDAP_BASE) { // eslint-disable-next-line no-console console.warn('Skipping LDAP modify: DM_LDAP_BASE not set'); // @ts-ignore this.skip(); } // Initialize DNs after env vars are set testDN = `uid=testuser,${process.env.DM_LDAP_BASE}`; newDN = `uid=newtestuser,${process.env.DM_LDAP_BASE}`; }); describe('add', () => { afterEach(async () => { // Clean up: delete the test entry if it exists try { await ldapActions.delete(testDN); } catch (err) { // Ignore errors if the entry does not exist } }); it('should add a new entry successfully, modify it and delete it successfully', async () => { const entry = { objectClass: [ 'inetOrgPerson', 'organizationalPerson', 'person', 'top', ], cn: 'Test User', sn: 'User', uid: 'testuser', mail: 'test@test.org', }; const addResult = await ldapActions.add(testDN, entry); expect(addResult).to.be.true; // Verify the entry was added const searchOptions = { filter: '(uid=testuser)', paged: false, }; const searchResult = await ldapActions.search(searchOptions); expect((searchResult as SearchResult).searchEntries.length).to.equal(1); expect((searchResult as SearchResult).searchEntries[0].dn).to.equal( testDN ); await ldapActions.modify(testDN, { replace: { mail: 't@t.org' }, }); const modifiedResult = await ldapActions.search(searchOptions); expect((modifiedResult as SearchResult).searchEntries.length).to.equal( 1 ); expect( (modifiedResult as SearchResult).searchEntries[0].mail ).to.include('t@t.org'); await ldapActions.delete('testuser'); let result = await ldapActions.search({ filter: '(uid=testuser)' }); if (!(result as SearchResult).searchEntries) { const tmp = await (result as AsyncGenerator).next(); result = tmp.value; } expect((result as SearchResult).searchEntries.length).to.equal(0); }); it('should fail to add an entry that already exists', async () => { const entry = { objectClass: [ 'inetOrgPerson', 'organizationalPerson', 'person', 'top', ], cn: 'Test User', sn: 'User', uid: 'testuser', mail: 'test@test.org', }; // First add const firstAdd = await ldapActions.add(testDN, entry); expect(firstAdd).to.be.true; // Second add should fail try { await ldapActions.add(testDN, entry); expect.fail('Expected error not thrown'); } catch (err) { expect(err).to.have.property('message'); } }); }); describe('rename', () => { afterEach(async () => { // Clean up: delete the test entry if it exists try { await ldapActions.delete(testDN); } catch (err) { // Ignore errors if the entry does not exist } try { await ldapActions.delete(newDN); } catch (err) { // Ignore errors if the entry does not exist } }); it('should rename an existing entry successfully', async () => { const entry = { objectClass: [ 'inetOrgPerson', 'organizationalPerson', 'person', 'top', ], cn: 'Test User', sn: 'User', uid: 'testuser', mail: 'test@test.org', }; const addResult = await ldapActions.add(testDN, entry); expect(addResult).to.be.true; // Rename the entry const renameResult = await ldapActions.rename(testDN, newDN); expect(renameResult).to.be.true; // Verify the old DN no longer exists let result = await ldapActions.search({ filter: '(uid=testuser)' }); if (!(result as SearchResult).searchEntries) { const tmp = await (result as AsyncGenerator).next(); result = tmp.value; } expect((result as SearchResult).searchEntries.length).to.equal(0); // Verify the new DN exists result = await ldapActions.search({ filter: '(uid=newtestuser)' }); if (!(result as SearchResult).searchEntries) { const tmp = await (result as AsyncGenerator).next(); result = tmp.value; } expect((result as SearchResult).searchEntries.length).to.equal(1); expect((result as SearchResult).searchEntries[0].dn).to.equal(newDN); expect((result as SearchResult).searchEntries[0].uid).to.equal( 'newtestuser' ); }); }); }); }); linagora-ldap-rest-16e557e/test/lib/parseConfig.test.ts000066400000000000000000000133371522642357000230720ustar00rootroot00000000000000import { expect } from 'chai'; import { ConfigParser } from '../../src/lib/parseConfig'; import configArgs from '../../src/config/args'; describe('ConfigParser', () => { afterEach(() => { delete process.env.DM_LLNG_INI; delete process.env.DM_PORT; delete process.env.DM_PLUGINS; }); it('should use default values when no env or cli args', () => { const parser = new ConfigParser(configArgs); const result = parser.parse(['node', 'script.js']); expect(result.port).to.equal(8081); expect(result.llng_ini).to.equal('/etc/lemonldap-ng/lemonldap-ng.ini'); }); it('should override with environment variables', () => { process.env.DM_LLNG_INI = '/env/foo'; process.env.DM_PORT = '100'; const parser = new ConfigParser(configArgs); const result = parser.parse(['node', 'script.js']); expect(result.port).to.equal(100); expect(result.llng_ini).to.equal('/env/foo'); }); it('should override with CLI arguments', () => { const argv = [ 'node', 'script.js', '--llng-ini', '/cli/foo', '--port', '77', ]; const parser = new ConfigParser(configArgs); const result = parser.parse(argv); expect(result.llng_ini).to.equal('/cli/foo'); expect(result.port).to.equal(77); }); it('should prioritize CLI over env', () => { process.env.DM_LLNG_INI = '/env/foo'; process.env.DM_PORT = '100'; const argv = [ 'node', 'script.js', '--llng-ini', '/cli/foo', '--port', '77', ]; const parser = new ConfigParser(configArgs); const result = parser.parse(argv); expect(result.llng_ini).to.equal('/cli/foo'); expect(result.port).to.equal(77); }); it('should parse array from env variable', () => { process.env.DM_PLUGINS = 'a,b, c d'; const parser = new ConfigParser(configArgs); const result = parser.parse(['node', 'script.js']); expect(result).to.have.property('plugin').that.is.an('array'); expect(result.plugin).to.deep.equal(['a', 'b', 'c', 'd']); }); it('should parse array from CLI argument', () => { const argv = [ 'node', 'script.js', '--plugin', 'x', '--plugin', 'y', '--plugin', 'z', ]; const parser = new ConfigParser(configArgs); const result = parser.parse(argv); expect(result).to.have.property('plugin').that.is.an('array'); expect(result.plugin).to.deep.equal(['x', 'y', 'z']); }); it('should combine array from env and CLI', () => { process.env.DM_PLUGINS = 'a,b'; const argv = ['node', 'script.js', '--plugin', 'x', '--plugin', 'y']; const parser = new ConfigParser(configArgs); const result = parser.parse(argv); expect(result).to.have.property('plugin').that.is.an('array'); expect(result.plugin).to.deep.equal(['a', 'b', 'x', 'y']); }); it('should parse command line argument with plural suffix for arrays', () => { const argv = ['node', 'script.js', '--plugins', 'm, n o']; const parser = new ConfigParser(configArgs); const result = parser.parse(argv); expect(result).to.have.property('plugin').that.is.an('array'); expect(result.plugin).to.deep.equal(['m', 'n', 'o']); }); it('should combine plural CLI array with singular CLI array', () => { const argv = [ 'node', 'script.js', '--plugins', 'm, n', '--plugin', 'x', '--plugin', 'y', ]; const parser = new ConfigParser(configArgs); const result = parser.parse(argv); expect(result).to.have.property('plugin').that.is.an('array'); expect(result.plugin).to.deep.equal(['x', 'y', 'm', 'n']); }); it('should combine plural CLI array with env array', () => { process.env.DM_PLUGINS = 'a,b'; const argv = ['node', 'script.js', '--plugins', 'm, n o']; const parser = new ConfigParser(configArgs); const result = parser.parse(argv); expect(result).to.have.property('plugin').that.is.an('array'); expect(result.plugin).to.deep.equal(['a', 'b', 'm', 'n', 'o']); }); it('should combine plural CLI array with env and singular CLI arrays', () => { process.env.DM_PLUGINS = 'a,b'; const argv = [ 'node', 'script.js', '--plugins', 'm, n', '--plugin', 'x', '--plugin', 'y', ]; const parser = new ConfigParser(configArgs); const result = parser.parse(argv); expect(result).to.have.property('plugin').that.is.an('array'); expect(result.plugin).to.deep.equal(['a', 'b', 'x', 'y', 'm', 'n']); }); it('should store additional command-line args', () => { const argv = [ 'node', 'script.js', '--plugins', 'm, n', '--zig-zag', 'test', ]; const parser = new ConfigParser(configArgs); const result = parser.parse(argv); expect(result).to.have.property('zig_zag').that.equals('test'); }); /* it('should handle short CLI args', () => { const argv = ['node', 'script.js', '-s', 'shortval']; const parser = new ConfigParser(config); const result = parser.parse(argv); expect(result.s).to.equal('shortval'); }); it('should parseConfig as a shortcut', () => { const argv = ['node', 'script.js', '--foo', 'shortcut']; const result = parseConfig(config, argv); expect(result.foo).to.equal('shortcut'); }); it('should treat missing integer CLI value as NaN', () => { const argv = ['node', 'script.js', '--baz']; const parser = new ConfigParser(config); const result = parser.parse(argv); expect(result.baz).to.be.NaN; }); it('should treat missing boolean CLI value as true', () => { const argv = ['node', 'script.js', '--flag']; const parser = new ConfigParser(config); const result = parser.parse(argv); expect(result.flag).to.equal(true); }); */ }); linagora-ldap-rest-16e557e/test/lib/utils.test.ts000066400000000000000000000250211522642357000217630ustar00rootroot00000000000000import { expect } from 'chai'; import { escapeDnValue, unescapeDnValue, escapeLdapFilter, validateDnValue, parseDn, getParentDn, getRdn, isChildOf, normalizeDn, isDnInBranch, } from '../../src/lib/utils'; describe('LDAP Utils', () => { describe('escapeDnValue', () => { it('should escape comma in DN values', () => { expect(escapeDnValue('Smith, John')).to.equal('Smith\\, John'); }); it('should escape plus sign in DN values', () => { expect(escapeDnValue('user+admin')).to.equal('user\\+admin'); }); it('should escape backslash in DN values', () => { expect(escapeDnValue('path\\to\\file')).to.equal('path\\\\to\\\\file'); }); it('should escape multiple special characters', () => { expect(escapeDnValue('a,b+c=d')).to.equal('a\\,b\\+c\\=d'); }); it('should escape leading space', () => { expect(escapeDnValue(' leadingspace')).to.equal('\\ leadingspace'); }); it('should escape trailing space', () => { expect(escapeDnValue('trailingspace ')).to.equal('trailingspace\\ '); }); it('should escape leading hash', () => { expect(escapeDnValue('#comment')).to.equal('\\#comment'); }); it('should escape quotes and angle brackets', () => { expect(escapeDnValue('"test"')).to.equal('\\"test\\"'); expect(escapeDnValue('')).to.equal('\\'); }); it('should handle normal strings without escaping', () => { expect(escapeDnValue('normaluser123')).to.equal('normaluser123'); }); }); describe('unescapeDnValue', () => { it('should unescape comma in DN values', () => { expect(unescapeDnValue('Smith\\, John')).to.equal('Smith, John'); }); it('should unescape plus sign in DN values', () => { expect(unescapeDnValue('user\\+admin')).to.equal('user+admin'); }); it('should unescape backslash in DN values', () => { expect(unescapeDnValue('path\\\\to\\\\file')).to.equal('path\\to\\file'); }); it('should unescape multiple special characters', () => { expect(unescapeDnValue('a\\,b\\+c\\=d')).to.equal('a,b+c=d'); }); it('should unescape leading space', () => { expect(unescapeDnValue('\\ leadingspace')).to.equal(' leadingspace'); }); it('should unescape trailing space', () => { expect(unescapeDnValue('trailingspace\\ ')).to.equal('trailingspace '); }); it('should unescape leading hash', () => { expect(unescapeDnValue('\\#comment')).to.equal('#comment'); }); it('should unescape quotes and angle brackets', () => { expect(unescapeDnValue('\\"test\\"')).to.equal('"test"'); expect(unescapeDnValue('\\')).to.equal(''); }); it('should handle hex-encoded characters', () => { expect(unescapeDnValue('user\\00name')).to.equal('user\0name'); expect(unescapeDnValue('path\\5cname')).to.equal('path\\name'); }); it('should handle normal strings without changes', () => { expect(unescapeDnValue('normaluser123')).to.equal('normaluser123'); }); it('should be the inverse of escapeDnValue', () => { const original = 'Smith, John+Admin'; expect(unescapeDnValue(escapeDnValue(original))).to.equal(original); }); it('should roundtrip complex values', () => { const values = [ 'user+admin', 'Smith, John', 'path\\to\\file', '"quoted"', '', '#comment', ' leadingspace', 'trailingspace ', 'a,b+c=df;g"h', ]; for (const val of values) { expect(unescapeDnValue(escapeDnValue(val))).to.equal(val); } }); }); describe('escapeLdapFilter', () => { it('should escape asterisk in filter values', () => { expect(escapeLdapFilter('user*')).to.equal('user\\2a'); }); it('should escape parentheses in filter values', () => { expect(escapeLdapFilter('(admin)')).to.equal('\\28admin\\29'); }); it('should escape backslash in filter values', () => { expect(escapeLdapFilter('path\\name')).to.equal('path\\5cname'); }); it('should handle complex filter injection attempts', () => { // Attempt to inject: )(uid=*) const malicious = ')(uid=*)'; const escaped = escapeLdapFilter(malicious); expect(escaped).to.equal('\\29\\28uid=\\2a\\29'); }); }); describe('validateDnValue', () => { it('should accept valid alphanumeric values', () => { expect(() => validateDnValue('user123', 'uid')).to.not.throw(); }); it('should accept values with allowed special characters', () => { expect(() => validateDnValue('john.doe@example.com', 'mail') ).to.not.throw(); expect(() => validateDnValue('Smith, John', 'cn')).to.not.throw(); expect(() => validateDnValue("O'Brien", 'sn')).to.not.throw(); }); it('should reject null character', () => { expect(() => validateDnValue('user\x00name', 'uid')).to.throw( 'uid contains invalid control characters' ); }); it('should reject newline characters', () => { expect(() => validateDnValue('user\nname', 'uid')).to.throw( 'uid contains invalid control characters' ); expect(() => validateDnValue('user\rname', 'uid')).to.throw( 'uid contains invalid control characters' ); }); it('should reject tab character', () => { expect(() => validateDnValue('user\tname', 'uid')).to.throw( 'uid contains invalid control characters' ); }); it('should reject zero-width space', () => { expect(() => validateDnValue('user\u200Bname', 'uid')).to.throw( 'uid contains invalid invisible characters' ); }); it('should reject BOM character', () => { expect(() => validateDnValue('\uFEFFuser', 'uid')).to.throw( 'uid contains invalid invisible characters' ); }); it('should reject empty string', () => { expect(() => validateDnValue('', 'uid')).to.throw( 'uid must be a non-empty string' ); }); it('should reject whitespace-only string', () => { expect(() => validateDnValue(' ', 'uid')).to.throw( 'uid must be a non-empty string' ); }); it('should include field name in error message', () => { expect(() => validateDnValue('bad\x00value', 'organizationalUnit') ).to.throw('organizationalUnit contains invalid control characters'); }); }); describe('parseDn', () => { it('should parse simple DN', () => { const parts = parseDn('uid=user,ou=users,dc=example,dc=com'); expect(parts).to.deep.equal([ 'uid=user', 'ou=users', 'dc=example', 'dc=com', ]); }); it('should handle escaped commas in DN', () => { const parts = parseDn('cn=Smith\\, John,ou=users,dc=example,dc=com'); expect(parts).to.deep.equal([ 'cn=Smith\\, John', 'ou=users', 'dc=example', 'dc=com', ]); }); }); describe('getParentDn', () => { it('should return parent DN', () => { expect(getParentDn('uid=user,ou=users,dc=example,dc=com')).to.equal( 'ou=users,dc=example,dc=com' ); }); it('should return same DN if no parent', () => { expect(getParentDn('dc=com')).to.equal('dc=com'); }); }); describe('getRdn', () => { it('should return first RDN component', () => { expect(getRdn('uid=user,ou=users,dc=example,dc=com')).to.equal( 'uid=user' ); }); }); describe('isChildOf', () => { it('should return true for direct child', () => { expect( isChildOf( 'uid=user,ou=users,dc=example,dc=com', 'ou=users,dc=example,dc=com' ) ).to.be.true; }); it('should return false for same DN', () => { expect( isChildOf('ou=users,dc=example,dc=com', 'ou=users,dc=example,dc=com') ).to.be.false; }); it('should return false for unrelated DNs', () => { expect( isChildOf( 'uid=user,ou=users,dc=example,dc=com', 'ou=groups,dc=example,dc=com' ) ).to.be.false; }); }); describe('normalizeDn', () => { it('should lowercase and drop whitespace around separators', () => { expect(normalizeDn('UID=x, ou=AppAccounts ,dc=Example,dc=com')).to.equal( 'uid=x,ou=appaccounts,dc=example,dc=com' ); }); it('should collapse whitespace around the = of an RDN', () => { expect(normalizeDn('ou = AppAccounts , dc = example')).to.equal( 'ou=appaccounts,dc=example' ); }); it('should order multi-valued RDN assertions deterministically', () => { expect(normalizeDn('ou=b+cn=a,dc=example')).to.equal( normalizeDn('cn=a+ou=b,dc=example') ); }); it('should preserve escaped commas as a single RDN', () => { expect(normalizeDn('cn=Smith\\, John,ou=users')).to.equal( 'cn=smith\\, john,ou=users' ); }); }); describe('isDnInBranch', () => { it('should return true for a descendant', () => { expect( isDnInBranch( 'uid=alice_c1,ou=appAccounts,dc=example,dc=com', 'ou=appAccounts,dc=example,dc=com' ) ).to.be.true; }); it('should return true for the base itself', () => { expect( isDnInBranch( 'ou=appAccounts,dc=example,dc=com', 'ou=appAccounts,dc=example,dc=com' ) ).to.be.true; }); it('should be insensitive to case and whitespace (the cascade-guard hardening)', () => { expect( isDnInBranch( 'uid=alice_c1, ou=AppAccounts, dc=example, dc=com', 'ou=appaccounts,dc=example,dc=com' ) ).to.be.true; }); it('should tolerate a trailing newline/space in the configured base', () => { expect( isDnInBranch( 'uid=alice_c1,ou=appAccounts,dc=example,dc=com', 'ou=appAccounts,dc=example,dc=com\n' ) ).to.be.true; }); it('should return false for a different branch', () => { expect( isDnInBranch( 'uid=user,ou=users,dc=example,dc=com', 'ou=appAccounts,dc=example,dc=com' ) ).to.be.false; }); it('should not match on a non-aligned suffix', () => { // "accounts,dc=example,dc=com" is a raw-string suffix but NOT an RDN-aligned one expect( isDnInBranch( 'uid=x,ou=otheraccounts,dc=example,dc=com', 'ou=accounts,dc=example,dc=com' ) ).to.be.false; }); it('should return false when base is empty', () => { expect(isDnInBranch('uid=x,ou=a,dc=c', '')).to.be.false; }); }); }); linagora-ldap-rest-16e557e/test/plugins/000077500000000000000000000000001522642357000202105ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/plugins/auth/000077500000000000000000000000001522642357000211515ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/plugins/auth/authzDynamic.behavior.test.ts000066400000000000000000000216441522642357000267440ustar00rootroot00000000000000/** * Regression tests for authzDynamic behavioural fixes from the PR #57 review. * * Each test names the reviewer finding it guards. */ import { expect } from 'chai'; import supertest from 'supertest'; import AuthzDynamic, { authzContext, } from '../../../src/plugins/auth/authzDynamic'; import LdapGroups from '../../../src/plugins/ldap/groups'; import { DM } from '../../../src/bin'; import { ssha } from '../../../src/plugins/auth/authzDynamicHash'; describe('authzDynamic behavioural regressions', function () { let server: DM; let plugin: AuthzDynamic; let groupsPlugin: LdapGroups; let baseDn: string; let tokensOu: string; let savedBase: string | undefined; let savedTtl: string | undefined; const tokenJsonTenant = 'tok-json-tenant-7777'; const tokenAttrTenant = 'tok-attr-tenant-8888'; before(async function () { this.timeout(30000); if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_BASE ) { // eslint-disable-next-line no-console console.warn( 'Skipping authzDynamic behavioural tests: LDAP env vars missing' ); this.skip(); return; } baseDn = process.env.DM_LDAP_BASE; tokensOu = `ou=authz-behavior-tokens,${baseDn}`; savedBase = process.env.DM_AUTHZ_DYNAMIC_BASE; savedTtl = process.env.DM_AUTHZ_DYNAMIC_CACHE_TTL; process.env.DM_AUTHZ_DYNAMIC_BASE = tokensOu; process.env.DM_AUTHZ_DYNAMIC_CACHE_TTL = '1'; process.env.DM_LDAP_GROUP_BASE = `ou=groups,${baseDn}`; process.env.DM_GROUP_SCHEMA = ''; server = new DM(); plugin = new AuthzDynamic(server); plugin.api(server.app); if (plugin.hooks) { for (const [name, fn] of Object.entries(plugin.hooks)) { if (!fn) continue; const list = (server.hooks[name] = server.hooks[name] || ([] as unknown[] as never)); (list as unknown as Array).push(fn as unknown); } } groupsPlugin = new LdapGroups(server); groupsPlugin.api(server.app); if (groupsPlugin.hooks) { for (const [name, fn] of Object.entries(groupsPlugin.hooks)) { if (!fn) continue; const list = (server.hooks[name] = server.hooks[name] || ([] as unknown[] as never)); (list as unknown as Array).push(fn as unknown); } } server.loadedPlugins['authzDynamic'] = plugin; server.loadedPlugins['ldapGroups'] = groupsPlugin; await server.ready; try { await plugin.server.ldap.add(tokensOu, { objectClass: ['top', 'organizationalUnit'], ou: 'authz-behavior-tokens', }); } catch { /* may already exist */ } // Entry A: tenant comes from the JSON config (should override cn). await plugin.server.ldap.add(`cn=json-entry,${tokensOu}`, { objectClass: ['top', 'inetOrgPerson'], cn: 'json-entry', sn: 'json-entry', userPassword: ssha(tokenJsonTenant), description: JSON.stringify({ tenant: 'explicit-json-tenant', bases: [ { dn: `ou=groups,${baseDn}`, read: true, write: false, delete: false, }, ], }), }); // Entry B: no tenant in JSON → falls back to cn (the configured attr). await plugin.server.ldap.add(`cn=attr-entry,${tokensOu}`, { objectClass: ['top', 'inetOrgPerson'], cn: 'attr-entry', sn: 'attr-entry', userPassword: ssha(tokenAttrTenant), description: JSON.stringify({ bases: [ { dn: `ou=groups,${baseDn}`, read: true, write: false, delete: false, }, ], }), }); await plugin.reload(); }); after(async () => { if (plugin) { for (const cn of ['json-entry', 'attr-entry']) { try { await plugin.server.ldap.delete(`cn=${cn},${tokensOu}`); } catch { /* ignore */ } } try { await plugin.server.ldap.delete(tokensOu); } catch { /* ignore */ } } if (savedBase === undefined) delete process.env.DM_AUTHZ_DYNAMIC_BASE; else process.env.DM_AUTHZ_DYNAMIC_BASE = savedBase; if (savedTtl === undefined) delete process.env.DM_AUTHZ_DYNAMIC_CACHE_TTL; else process.env.DM_AUTHZ_DYNAMIC_CACHE_TTL = savedTtl; }); describe('parsed.tenant honoured (Copilot)', () => { it('uses the JSON `tenant` field when present', () => { const tokens = plugin._tokens(); const entry = tokens.find(t => t.cn === 'json-entry'); expect(entry, 'json-entry should be loaded').to.exist; expect(entry!.tenant).to.equal('explicit-json-tenant'); }); it('falls back to the tenantAttribute (cn) when JSON omits tenant', () => { const tokens = plugin._tokens(); const entry = tokens.find(t => t.cn === 'attr-entry'); expect(entry, 'attr-entry should be loaded').to.exist; expect(entry!.tenant).to.equal('attr-entry'); }); it('authenticated request carries the JSON tenant as req.user', async () => { // supertest → auth middleware sets req.user = match.tenant // We can't inspect req.user directly; instead, verify the ACL matches // as the JSON-tenant-carrier would get read on groups. await supertest(server.app) .get('/api/v1/ldap/groups') .set('Authorization', `Bearer ${tokenJsonTenant}`) .expect(200); }); }); describe('Forbidden denial — 403 with no sensitive leakage', () => { it('denies write with 403 and generic message (no token name, no DN)', async () => { const res = await supertest(server.app) .post('/api/v1/ldap/groups') .set('Authorization', `Bearer ${tokenJsonTenant}`) .set('Content-Type', 'application/json') .send({ cn: 'some-group' }) .expect(403); expect(res.body.error).to.match(/permission/i); expect(res.body.error).to.not.match(/explicit-json-tenant/); expect(res.body.error).to.not.match(/ou=groups/); expect(res.body.error).to.not.match(/\[authz-forbidden\]/); }); }); describe('reload failure backoff', () => { it('does not touch lastLoad but records lastFailure when reload fails', async () => { // Point the plugin's base at a non-existent branch → reload fails // Note: we can't easily swap the base on a live plugin. Instead, we // verify the behaviour by forcing a failure through deleting a required // attribute… easier: simulate by calling reload on a plugin configured // with an unreachable base. const hijack = new AuthzDynamic({ ...server, config: { ...server.config, authz_dynamic_base: `ou=does-not-exist,${baseDn}`, }, logger: server.logger, hooks: server.hooks, ldap: server.ldap, operationSequence: 0, app: server.app, loadedPlugins: server.loadedPlugins, } as unknown as DM); // Attempt reload — should log an error but not throw await hijack.reload(); const dataBefore = hijack.getConfigApiData(); expect(dataBefore.tokenCount).to.equal(0); // Second reload right away should not make another LDAP call because // the failure-backoff window blocks it. We can only assert this // through observed behaviour: lastFailure is positive, so another // request routed through authMethod should still return 401 quickly. // This is a smoke test; deeper timing assertions would need clock // injection. }); }); describe('composable auth (req.user already set)', () => { it('short-circuits authMethod when req.user is already set by another middleware', async () => { // Simulate a prior auth middleware that set req.user. We add it to the // stack AFTER the SCIM auth, but since Express runs in order and the // authzDynamic middleware is in place already, we piggyback via a hook: // set req.user in beforeAuth. const key = 'preexisting-user'; server.hooks.beforeAuth = server.hooks.beforeAuth || []; (server.hooks.beforeAuth as Array).push( (args: [{ user?: string }, unknown]) => { args[0].user = key; return args; } ); // Call a route with NO Authorization header — would normally 401. // With the short-circuit, it should proceed past authMethod; but the // authzDynamic hooks still gate on the active token from // AsyncLocalStorage. Since the short-circuit runs `next()` without // entering the `authzContext.run` frame, no token is active, and the // authz hooks pass through (they no-op when no token). // We expect the request to reach the route handler (no 401). const res = await supertest(server.app).get('/api/v1/ldap/groups'); expect(res.status).to.not.equal(401); // Clean up the hook (server.hooks.beforeAuth as Array).pop(); expect(authzContext.getStore(), 'no leaked frame').to.be.undefined; }); }); }); linagora-ldap-rest-16e557e/test/plugins/auth/authzDynamic.test.ts000066400000000000000000000154731522642357000251510ustar00rootroot00000000000000import { expect } from 'chai'; import supertest from 'supertest'; import AuthzDynamic from '../../../src/plugins/auth/authzDynamic'; import LdapGroups from '../../../src/plugins/ldap/groups'; import { DM } from '../../../src/bin'; import { ssha } from '../../../src/plugins/auth/authzDynamicHash'; describe('authzDynamic (integration)', function () { let server: DM; let plugin: AuthzDynamic; let groupsPlugin: LdapGroups; let baseDn: string; let tokensOu: string; const validToken = 'unit-test-secret-4242'; const foreignToken = 'foreign-secret-1111'; before(async function () { this.timeout(30000); if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_BASE ) { // eslint-disable-next-line no-console console.warn('Skipping authzDynamic tests: LDAP env vars missing'); this.skip(); return; } baseDn = process.env.DM_LDAP_BASE; tokensOu = `ou=authz-tokens,${baseDn}`; process.env.DM_AUTHZ_DYNAMIC_BASE = tokensOu; process.env.DM_LDAP_GROUP_BASE = `ou=groups,${baseDn}`; // Disable the Twake group schema so the plain CRUD works in this test. process.env.DM_GROUP_SCHEMA = ''; // Short TTL so tests see reloads promptly process.env.DM_AUTHZ_DYNAMIC_CACHE_TTL = '1'; server = new DM(); plugin = new AuthzDynamic(server); plugin.api(server.app); // Register the plugin's hooks with the server (the plugin loader normally // does this when plugins are loaded via --plugin; here we instantiate // directly so we have to wire hooks ourselves). if (plugin.hooks) { for (const [name, fn] of Object.entries(plugin.hooks)) { if (!fn) continue; const list = (server.hooks[name] = server.hooks[name] || ([] as unknown[] as never)); (list as unknown as Array).push(fn as unknown); } } groupsPlugin = new LdapGroups(server); groupsPlugin.api(server.app); if (groupsPlugin.hooks) { for (const [name, fn] of Object.entries(groupsPlugin.hooks)) { if (!fn) continue; const list = (server.hooks[name] = server.hooks[name] || ([] as unknown[] as never)); (list as unknown as Array).push(fn as unknown); } } server.loadedPlugins['authzDynamic'] = plugin; server.loadedPlugins['ldapGroups'] = groupsPlugin; await server.ready; // Create tokens OU + two token entries: one allowed on groups, one foreign try { await plugin.server.ldap.add(tokensOu, { objectClass: ['top', 'organizationalUnit'], ou: 'authz-tokens', }); } catch { /* may already exist */ } await plugin.server.ldap.add(`cn=allowed,${tokensOu}`, { objectClass: ['top', 'inetOrgPerson'], cn: 'allowed', sn: 'allowed', userPassword: ssha(validToken), description: JSON.stringify({ tenant: 'allowed', bases: [ { dn: `ou=groups,${baseDn}`, read: true, write: true, delete: true, }, ], }), }); await plugin.server.ldap.add(`cn=foreign,${tokensOu}`, { objectClass: ['top', 'inetOrgPerson'], cn: 'foreign', sn: 'foreign', userPassword: ssha(foreignToken), description: JSON.stringify({ tenant: 'foreign', bases: [ { dn: `ou=users,${baseDn}`, // explicitly NOT groups read: true, write: true, delete: true, }, ], }), }); // Force reload now (constructor did not run one yet) await plugin.reload(); }); after(async () => { if (!plugin) return; for (const cn of ['allowed', 'foreign']) { try { await plugin.server.ldap.delete(`cn=${cn},${tokensOu}`); } catch { /* ignore */ } } try { await plugin.server.ldap.delete(tokensOu); } catch { /* ignore */ } }); afterEach(async () => { try { await plugin.server.ldap.delete(`cn=probe-group,ou=groups,${baseDn}`); } catch { /* ignore */ } }); it('rejects requests without a Bearer token', async () => { await supertest(server.app).get('/api/v1/ldap/groups').expect(401); }); it('rejects requests with an unknown token', async () => { await supertest(server.app) .get('/api/v1/ldap/groups') .set('Authorization', 'Bearer this-token-does-not-exist') .expect(401); }); it('authorizes a listing on a permitted branch', async () => { await supertest(server.app) .get('/api/v1/ldap/groups') .set('Authorization', `Bearer ${validToken}`) .expect(200); }); it('forbids a listing on a branch outside the token ACL with 403', async () => { const res = await supertest(server.app) .get('/api/v1/ldap/groups') .set('Authorization', `Bearer ${foreignToken}`) .expect(403); expect(res.body.error).to.match(/permission/i); // Never leak the tenant name or target DN in the client-facing message expect(res.body.error).to.not.match(/foreign/); expect(res.body.error).to.not.match(/ou=/); }); it('allows a write on a permitted branch', async () => { await supertest(server.app) .post('/api/v1/ldap/groups') .set('Authorization', `Bearer ${validToken}`) .set('Content-Type', 'application/json') .send({ cn: 'probe-group' }) .expect(200); }); it('forbids a write on a branch outside the token ACL with 403', async () => { const res = await supertest(server.app) .post('/api/v1/ldap/groups') .set('Authorization', `Bearer ${foreignToken}`) .set('Content-Type', 'application/json') .send({ cn: 'should-not-be-created' }) .expect(403); expect(res.body.error).to.match(/permission/i); }); it('exposes a stable token count via getConfigApiData()', () => { const data = plugin.getConfigApiData(); expect(data.enabled).to.be.true; expect(data.tokenCount).to.equal(2); expect(data.base).to.equal(tokensOu); }); it('picks up a new token after a reload', async () => { const rolling = 'rolling-token-xxx'; await plugin.server.ldap.add(`cn=rolling,${tokensOu}`, { objectClass: ['top', 'inetOrgPerson'], cn: 'rolling', sn: 'rolling', userPassword: ssha(rolling), description: JSON.stringify({ tenant: 'rolling', bases: [ { dn: `ou=groups,${baseDn}`, read: true, write: false, delete: false, }, ], }), }); try { await plugin.reload(); await supertest(server.app) .get('/api/v1/ldap/groups') .set('Authorization', `Bearer ${rolling}`) .expect(200); } finally { await plugin.server.ldap.delete(`cn=rolling,${tokensOu}`).catch(() => {}); await plugin.reload(); } }); }); linagora-ldap-rest-16e557e/test/plugins/auth/authzDynamicHash.test.ts000066400000000000000000000143651522642357000257540ustar00rootroot00000000000000import { expect } from 'chai'; import crypto from 'crypto'; import { verifyLdapPassword, ssha, sshaHash, } from '../../../src/plugins/auth/authzDynamicHash'; function ldapify(prefix: string, digest: Buffer): string { return `{${prefix}}${digest.toString('base64')}`; } describe('verifyLdapPassword', () => { describe('{SSHA}', () => { it('accepts the correct password', () => { const stored = ssha('s3cret'); expect(verifyLdapPassword('s3cret', stored)).to.be.true; }); it('rejects the wrong password', () => { const stored = ssha('s3cret'); expect(verifyLdapPassword('s3crat', stored)).to.be.false; }); it('rejects malformed base64', () => { expect(verifyLdapPassword('x', '{SSHA}***not-base64***')).to.be.false; }); it('rejects truncated payload', () => { // Less than digestLength → cannot extract salt expect(verifyLdapPassword('x', '{SSHA}dGVzdA==')).to.be.false; }); }); describe('{SHA}', () => { it('accepts the correct password', () => { const digest = crypto.createHash('sha1').update('s3cret').digest(); expect(verifyLdapPassword('s3cret', ldapify('SHA', digest))).to.be.true; }); it('rejects the wrong password', () => { const digest = crypto.createHash('sha1').update('s3cret').digest(); expect(verifyLdapPassword('nope', ldapify('SHA', digest))).to.be.false; }); }); describe('{SSHA256}', () => { it('accepts the correct password', () => { const salt = Buffer.from('saltysalt', 'utf8'); const hash = crypto .createHash('sha256') .update('s3cret') .update(salt) .digest(); const stored = `{SSHA256}${Buffer.concat([hash, salt]).toString('base64')}`; expect(verifyLdapPassword('s3cret', stored)).to.be.true; }); it('rejects the wrong password', () => { const salt = Buffer.from('saltysalt', 'utf8'); const hash = crypto .createHash('sha256') .update('s3cret') .update(salt) .digest(); const stored = `{SSHA256}${Buffer.concat([hash, salt]).toString('base64')}`; expect(verifyLdapPassword('s3crit', stored)).to.be.false; }); }); describe('{SSHA512}', () => { it('accepts the correct password', () => { const salt = crypto.randomBytes(16); const hash = crypto .createHash('sha512') .update('s3cret') .update(salt) .digest(); const stored = `{SSHA512}${Buffer.concat([hash, salt]).toString('base64')}`; expect(verifyLdapPassword('s3cret', stored)).to.be.true; }); }); describe('{SHA256}', () => { it('accepts the correct password', () => { const digest = crypto.createHash('sha256').update('s3cret').digest(); expect(verifyLdapPassword('s3cret', ldapify('SHA256', digest))).to.be .true; }); }); describe('cleartext', () => { it('plain string with no prefix matches', () => { expect(verifyLdapPassword('plaintoken', 'plaintoken')).to.be.true; }); it('plain string with no prefix mismatch', () => { expect(verifyLdapPassword('bad', 'plaintoken')).to.be.false; }); it('{CLEARTEXT} prefix matches', () => { expect(verifyLdapPassword('tok', '{CLEARTEXT}tok')).to.be.true; }); it('{PLAIN} prefix matches', () => { expect(verifyLdapPassword('tok', '{PLAIN}tok')).to.be.true; }); }); describe('safety', () => { it('rejects unknown scheme rather than silently matching', () => { expect(verifyLdapPassword('anything', '{UNKNOWN}data')).to.be.false; }); it('rejects empty provided', () => { expect(verifyLdapPassword('', '{SSHA}whatever')).to.be.false; }); it('rejects empty stored', () => { expect(verifyLdapPassword('tok', '')).to.be.false; }); }); describe('scheme prefix is case-insensitive', () => { it('accepts lowercase scheme prefix', () => { const digest = crypto.createHash('sha1').update('p').digest(); expect(verifyLdapPassword('p', `{sha}${digest.toString('base64')}`)).to.be .true; }); }); }); describe('ssha / sshaHash helpers', () => { it('ssha() produces an {SSHA} hash round-trippable with verifyLdapPassword', () => { const hash = ssha('round-trip'); expect(hash).to.match(/^\{SSHA\}/); expect(verifyLdapPassword('round-trip', hash)).to.be.true; expect(verifyLdapPassword('round-tripp', hash)).to.be.false; }); it('sshaHash("sha512", ...) produces a strong {SSHA512} hash', () => { const hash = sshaHash('sha512', 'strong'); expect(hash).to.match(/^\{SSHA512\}/); expect(verifyLdapPassword('strong', hash)).to.be.true; expect(verifyLdapPassword('weak', hash)).to.be.false; }); it('sshaHash("sha256", ...) produces an {SSHA256} hash', () => { const hash = sshaHash('sha256', 'medium'); expect(hash).to.match(/^\{SSHA256\}/); expect(verifyLdapPassword('medium', hash)).to.be.true; }); }); describe('canonical base64 validation (security)', () => { // Node's Buffer.from(str, 'base64') silently drops non-base64 characters. // Without strict validation, a crafted hash could be decoded with a // shorter effective payload than the stored scheme expects. These tests // assert that malformed or non-canonical inputs are REFUSED. const honestSsha = ssha('s3cret'); // Extract just the payload portion to mutate. const payload = honestSsha.replace('{SSHA}', ''); it('rejects base64 with length not divisible by 4', () => { // Strip one char so length%4 !== 0 (and not a valid base64 alphabet // position). const tampered = '{SSHA}' + payload.slice(0, -1); expect(verifyLdapPassword('s3cret', tampered)).to.be.false; }); it('rejects base64 containing garbage characters', () => { const tampered = '{SSHA}' + payload.slice(0, 4) + '!!@@' + payload.slice(8); expect(verifyLdapPassword('s3cret', tampered)).to.be.false; }); it('rejects base64 with extra whitespace', () => { const tampered = '{SSHA}' + payload.replace(/[A-Za-z0-9]/, ' '); expect(verifyLdapPassword('s3cret', tampered)).to.be.false; }); it('rejects base64 with non-canonical padding', () => { // Adding explicit padding that changes the re-encoded form const tampered = '{SSHA}' + payload + '==='; expect(verifyLdapPassword('s3cret', tampered)).to.be.false; }); }); linagora-ldap-rest-16e557e/test/plugins/auth/authzLinid1.test.ts000066400000000000000000001120561522642357000247000ustar00rootroot00000000000000import { expect } from 'chai'; import { DM } from '../../../src/bin'; import AuthzLinid1 from '../../../src/plugins/auth/authzLinid1'; import LdapOrganization from '../../../src/plugins/ldap/organizations'; import AuthBase, { type DmRequest } from '../../../src/lib/auth/base'; import type { Response } from 'express'; import type { Role } from '../../../src/abstract/plugin'; import supertest from 'supertest'; import { skipIfMissingEnvVars } from '../../helpers/env'; // Simple auth plugin for testing that sets user from X-Test-User header class TestAuthPlugin extends AuthBase { name = 'testAuth'; roles: Role[] = ['auth'] as const; authMethod(req: DmRequest, res: Response, next: () => void): void { // Use X-Test-User header to identify user, or fall back to token-based auth const testUser = req.headers['x-test-user']; if (testUser && typeof testUser === 'string') { req.user = testUser; return next(); } // Otherwise, require a valid token let token = req.headers['authorization']; if (!token || !/^Bearer .+/.test(token)) { res.status(401).json({ error: 'Unauthorized' }); return; } token = token.split(' ')[1]; if (!(this.config.auth_token as string[]).includes(token)) { res.status(401).json({ error: 'Unauthorized' }); return; } req.user = 'token number ' + (this.config.auth_token as string[]).indexOf(token); next(); } } describe('AuthzLinid1 Plugin', () => { let dm: DM; let authz: AuthzLinid1; // Use getters to ensure env vars are evaluated after setup const getTestOrgDn = () => `ou=TestOrg,${process.env.DM_LDAP_TOP_ORGANIZATION}`; const getTestUserDn = () => `uid=testadmin,${process.env.DM_LDAP_BASE}`; before(function () { skipIfMissingEnvVars(this, [ 'DM_LDAP_DN', 'DM_LDAP_PWD', 'DM_LDAP_TOP_ORGANIZATION', ]); }); beforeEach(async () => { dm = new DM(); await dm.ready; authz = new AuthzLinid1(dm); dm.registerPlugin('authzLinid1', authz); }); afterEach(async () => { // Clean up test entries try { await dm.ldap.delete(getTestOrgDn()); } catch (err) { // Ignore if doesn't exist } try { await dm.ldap.delete(getTestUserDn()); } catch (err) { // Ignore if doesn't exist } }); describe('getUserDn', () => { it('should resolve user DN from uid', async () => { // Create a test user const entry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), entry); const dn = await authz.getUserDn('testadmin'); expect(dn).to.equal(getTestUserDn()); }); it('should return null for non-existent user', async () => { const dn = await authz.getUserDn('nonexistent'); expect(dn).to.be.null; }); }); describe('getUserPermissions', () => { it('should grant permissions when user is in twakeLocalAdminLink', async () => { // Create test user const userEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), userEntry); // Create test organization with user as local admin const orgEntry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), orgEntry); // Get permissions const perms = await authz.getUserPermissions( getTestUserDn(), getTestOrgDn() ); expect(perms.read).to.be.true; expect(perms.write).to.be.true; expect(perms.delete).to.be.true; }); it('should deny permissions when user is not in twakeLocalAdminLink', async () => { // Create test user const userEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), userEntry); // Create test organization without user as local admin const orgEntry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', }; await dm.ldap.add(getTestOrgDn(), orgEntry); // Get permissions const perms = await authz.getUserPermissions( getTestUserDn(), getTestOrgDn() ); expect(perms.read).to.be.false; expect(perms.write).to.be.false; expect(perms.delete).to.be.false; }); it('should grant permissions for sub-branches when user has access to parent', async () => { // Create test user const userEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), userEntry); // Create parent organization with user as local admin const parentOrgEntry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), parentOrgEntry); // Check permissions for a sub-branch const subBranchDn = `ou=SubOrg,${getTestOrgDn()}`; const perms = await authz.getUserPermissions( getTestUserDn(), subBranchDn ); expect(perms.read).to.be.true; expect(perms.write).to.be.true; expect(perms.delete).to.be.true; }); }); describe('getAuthorizedBranches', () => { it('should return list of branches user manages', async () => { // Create test user const userEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), userEntry); // Create test organization with user as local admin const orgEntry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), orgEntry); // Get authorized branches const branches = await authz.getAuthorizedBranches(getTestUserDn()); expect(branches).to.be.an('array'); expect(branches).to.include(getTestOrgDn()); }); it('should return empty array when user has no permissions', async () => { // Create test user const userEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), userEntry); // Get authorized branches (no org with this user as admin) const branches = await authz.getAuthorizedBranches(getTestUserDn()); expect(branches).to.be.an('array'); expect(branches).to.have.lengthOf(0); }); }); describe('Cache', () => { it('should cache permissions', async () => { // Create test user const userEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), userEntry); // Create test organization const orgEntry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), orgEntry); // First call - should fetch from LDAP const perms1 = await authz.getUserPermissions( getTestUserDn(), getTestOrgDn() ); expect(perms1.read).to.be.true; // Second call - should use cache const perms2 = await authz.getUserPermissions( getTestUserDn(), getTestOrgDn() ); expect(perms2.read).to.be.true; // Verify cache was used const cached = authz.permissionsCache.get(getTestUserDn()); expect(cached).to.exist; expect(cached?.branches.size).to.be.greaterThan(0); }); }); describe('Integration with users branch', () => { const getTestUserInOrgDn = () => `uid=testuser,ou=users,${process.env.DM_LDAP_BASE}`; const getTestUser2InOrgDn = () => `uid=testuser2,ou=users,${process.env.DM_LDAP_BASE}`; const getTestOrg2Dn = () => `ou=TestOrg2,${process.env.DM_LDAP_TOP_ORGANIZATION}`; afterEach(async () => { // Clean up test users in users branch try { await dm.ldap.delete(getTestUserInOrgDn()); } catch (err) { // Ignore if doesn't exist } try { await dm.ldap.delete(getTestUser2InOrgDn()); } catch (err) { // Ignore if doesn't exist } try { await dm.ldap.delete(getTestOrg2Dn()); } catch (err) { // Ignore if doesn't exist } }); it('should allow admin to see users linked to their organization', async () => { // Create admin user const adminEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), adminEntry); // Create organization with admin const orgEntry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), orgEntry); // Create user in users branch linked to this organization const userEntry = { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], uid: 'testuser', sn: 'User', cn: 'Test User', twakeDepartmentLink: getTestOrgDn(), }; await dm.ldap.add(getTestUserInOrgDn(), userEntry); // Admin should have permissions to read the users branch const perms = await authz.getUserPermissions( getTestUserDn(), `ou=users,${process.env.DM_LDAP_BASE}` ); // Note: Admin has permissions on their org branch, not necessarily on ou=users // But they should be able to read users that belong to their org // This might require additional logic in the plugin or is handled by LDAP filters expect(perms).to.exist; }); it('should grant permissions for users branch when user manages an organization', async () => { // Create admin user const adminEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), adminEntry); // Create organization with admin const orgEntry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), orgEntry); // Get authorized branches const branches = await authz.getAuthorizedBranches(getTestUserDn()); // Should include the organization expect(branches).to.include(getTestOrgDn()); }); it('should return sub-organization as top for local admin via getOrganisationTop hook', async function () { if (!process.env.DM_LDAP_TOP_ORGANIZATION) { this.skip(); } // Load organization plugin const orgPlugin = new LdapOrganization(dm); dm.registerPlugin('ldapOrganizations', orgPlugin); // Create admin user const adminEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), adminEntry); // Create sub-organization with admin const orgEntry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), orgEntry); // Create a mock request with the admin user const req = { user: 'testadmin' } as any; // First verify that the user has been granted permissions const branches = await authz.getAuthorizedBranches(getTestUserDn()); expect(branches).to.include(getTestOrgDn()); // Call getOrganisationTop - should return the sub-org, not the top org const result = await orgPlugin.getOrganisationTop(req); // Should return the TestOrg, not the top organization expect(result).to.exist; const resultDn = typeof (result as any).dn === 'string' ? (result as any).dn : String((result as any).dn); expect(resultDn).to.equal(getTestOrgDn()); }); it('should deny admin access to users from other organizations', async () => { // Create admin user const adminEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), adminEntry); // Create organization 1 with admin const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), org1Entry); // Create organization 2 without admin const org2Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg2', twakeDepartmentPath: 'TestOrg2 / organization', }; await dm.ldap.add(getTestOrg2Dn(), org2Entry); // Create user in users branch linked to organization 2 const user2Entry = { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], uid: 'testuser2', sn: 'User2', cn: 'Test User 2', twakeDepartmentLink: getTestOrg2Dn(), }; await dm.ldap.add(getTestUser2InOrgDn(), user2Entry); // Get authorized branches - should only include org1, not org2 const branches = await authz.getAuthorizedBranches(getTestUserDn()); expect(branches).to.include(getTestOrgDn()); expect(branches).to.not.include(getTestOrg2Dn()); // Admin should not have permissions on org2 const perms = await authz.getUserPermissions( getTestUserDn(), getTestOrg2Dn() ); expect(perms.read).to.be.false; expect(perms.write).to.be.false; expect(perms.delete).to.be.false; }); }); describe('Out of scope access control via API', () => { let request: ReturnType; let orgPlugin: LdapOrganization; let authPlugin: TestAuthPlugin; const getTestOrg2Dn = () => `ou=TestOrg2,${process.env.DM_LDAP_TOP_ORGANIZATION}`; const getTestSubOrg1Dn = () => `ou=SubOrg1,${getTestOrgDn()}`; const getTestSubOrg2Dn = () => `ou=SubOrg2,${getTestOrg2Dn()}`; const adminToken = 'test-admin-token'; beforeEach(async () => { // Setup DM with auth token and organization plugins process.env.DM_AUTH_TOKENS = adminToken; dm = new DM(); await dm.ready; // Register plugins authz = new AuthzLinid1(dm); authPlugin = new TestAuthPlugin(dm); orgPlugin = new LdapOrganization(dm); await dm.registerPlugin('testAuth', authPlugin); await dm.registerPlugin('authzLinid1', authz); await dm.registerPlugin('ldapOrganizations', orgPlugin); orgPlugin.api(dm.app); request = supertest(dm.app); }); afterEach(async () => { // Clean up test entries try { await dm.ldap.delete(getTestSubOrg1Dn()); } catch (err) { // Ignore if doesn't exist } try { await dm.ldap.delete(getTestSubOrg2Dn()); } catch (err) { // Ignore if doesn't exist } try { await dm.ldap.delete(getTestOrg2Dn()); } catch (err) { // Ignore if doesn't exist } delete process.env.DM_AUTH_TOKENS; }); describe('READ - Search outside authorized scope', () => { it('should not allow search in unauthorized branch', async () => { // Create admin user const adminEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), adminEntry); // Create organization 1 with admin (authorized) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), org1Entry); // Create organization 2 without admin (unauthorized) const org2Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg2', twakeDepartmentPath: 'TestOrg2 / organization', }; await dm.ldap.add(getTestOrg2Dn(), org2Entry); // Try to get unauthorized org via API - should fail const res = await request .get( `/api/v1/ldap/organizations/${encodeURIComponent(getTestOrg2Dn())}` ) .set('Authorization', `Bearer ${adminToken}`) .set('X-Test-User', 'testadmin') .set('Accept', 'application/json'); expect(res.status).to.equal(403); expect(res.body).to.have.property('error'); expect(res.body.error).to.equal( 'Token does not have permission on this branch' ); }); it('should allow search in authorized branch', async () => { // Create admin user const adminEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), adminEntry); // Create organization 1 with admin (authorized) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), org1Entry); // Try to get authorized org via API - should succeed const res = await request .get( `/api/v1/ldap/organizations/${encodeURIComponent(getTestOrgDn())}` ) .set('Authorization', `Bearer ${adminToken}`) .set('X-Test-User', 'testadmin') .set('Accept', 'application/json'); expect(res.status).to.equal(200); expect(res.body).to.have.property('dn', getTestOrgDn()); expect(res.body).to.have.property('ou', 'TestOrg'); }); it('should not return entries from other branches in subnodes search', async () => { // Create admin user const adminEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), adminEntry); // Create organization 1 with admin (authorized) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), org1Entry); // Create organization 2 without admin (unauthorized) const org2Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg2', twakeDepartmentPath: 'TestOrg2 / organization', }; await dm.ldap.add(getTestOrg2Dn(), org2Entry); // Try to search subnodes of unauthorized org - should fail const res = await request .get( `/api/v1/ldap/organizations/${encodeURIComponent(getTestOrg2Dn())}/subnodes` ) .set('Authorization', `Bearer ${adminToken}`) .set('X-Test-User', 'testadmin') .set('Accept', 'application/json'); expect(res.status).to.equal(403); expect(res.body).to.have.property('error'); }); }); describe('WRITE - Add organizational unit outside scope', () => { it('should reject adding a sub-organization in an unauthorized branch', async () => { // Create admin user const adminEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), adminEntry); // Create organization 1 with admin (authorized) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), org1Entry); // Create organization 2 without admin (unauthorized) const org2Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg2', twakeDepartmentPath: 'TestOrg2 / organization', }; await dm.ldap.add(getTestOrg2Dn(), org2Entry); // Try to add a sub-org under unauthorized org2 via API const res = await request .post('/api/v1/ldap/organizations') .set('Authorization', `Bearer ${adminToken}`) .set('X-Test-User', 'testadmin') .type('json') .send({ ou: 'SubOrg2', parentDn: getTestOrg2Dn(), }); // Should be rejected expect(res.status).to.not.equal(200); // Verify nothing was written to LDAP using direct search try { const searchResult = await dm.ldap.search( { paged: false, scope: 'base', filter: '(objectClass=*)', }, getTestSubOrg2Dn() ); // If search succeeds, ensure it returned 0 entries expect((searchResult as any).searchEntries).to.have.lengthOf(0); } catch (err) { // NoSuchObjectError is expected - it means the entry was not created expect(err).to.be.instanceOf(Error); expect((err as any).code).to.equal(32); // LDAP NoSuchObject error code } }); it('should allow adding a sub-organization in an authorized branch', async () => { // Create admin user const adminEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), adminEntry); // Create organization 1 with admin (authorized) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), org1Entry); // Try to add a sub-org under authorized org1 via API const res = await request .post('/api/v1/ldap/organizations') .set('Authorization', `Bearer ${adminToken}`) .set('X-Test-User', 'testadmin') .type('json') .send({ ou: 'SubOrg1', parentDn: getTestOrgDn(), }); // Should succeed expect(res.status).to.equal(200); expect(res.body).to.have.property('success', true); // Verify it was written to LDAP using direct search const searchResult = await dm.ldap.search( { paged: false, scope: 'base', filter: '(objectClass=*)', }, getTestSubOrg1Dn() ); expect((searchResult as any).searchEntries).to.have.lengthOf(1); expect((searchResult as any).searchEntries[0].ou).to.equal('SubOrg1'); }); }); describe('WRITE - Move user between organizations', () => { const getTestUser1Dn = () => `uid=testuser1,ou=users,${process.env.DM_LDAP_BASE}`; afterEach(async () => { try { await dm.ldap.delete(getTestUser1Dn()); } catch (err) { // Ignore if doesn't exist } }); it('should allow moving user to authorized organization', async () => { // Create admin user const adminEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), adminEntry); // Create organization with admin (authorized) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), org1Entry); // Create second organization with admin (also authorized) const org2Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg2', twakeDepartmentPath: 'TestOrg2 / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrg2Dn(), org2Entry); // Create user in first organization const userEntry = { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], uid: 'testuser1', sn: 'User1', cn: 'Test User 1', twakeDepartmentLink: getTestOrgDn(), twakeDepartmentPath: 'TestOrg / organization', }; await dm.ldap.add(getTestUser1Dn(), userEntry); // Create mock request const mockReq = { user: 'testadmin' } as any; // Move user to second organization - should succeed await dm.ldap.modify( getTestUser1Dn(), { replace: { twakeDepartmentLink: getTestOrg2Dn(), twakeDepartmentPath: 'TestOrg2 / organization', }, }, mockReq ); // Verify it was updated const searchResult = await dm.ldap.search( { paged: false, scope: 'base', filter: '(objectClass=*)', }, getTestUser1Dn() ); expect((searchResult as any).searchEntries).to.have.lengthOf(1); expect( (searchResult as any).searchEntries[0].twakeDepartmentLink ).to.equal(getTestOrg2Dn()); }); it('should reject moving user to unauthorized organization', async () => { // Create admin user const adminEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), adminEntry); // Create organization 1 with admin (authorized) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), org1Entry); // Create organization 2 without admin (unauthorized) const org2Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg2', twakeDepartmentPath: 'TestOrg2 / organization', }; await dm.ldap.add(getTestOrg2Dn(), org2Entry); // Create user in first organization const userEntry = { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], uid: 'testuser1', sn: 'User1', cn: 'Test User 1', twakeDepartmentLink: getTestOrgDn(), twakeDepartmentPath: 'TestOrg / organization', }; await dm.ldap.add(getTestUser1Dn(), userEntry); // Create mock request const mockReq = { user: 'testadmin' } as any; // Try to move user to unauthorized organization - should be rejected try { await dm.ldap.modify( getTestUser1Dn(), { replace: { twakeDepartmentLink: getTestOrg2Dn(), twakeDepartmentPath: 'TestOrg2 / organization', }, }, mockReq ); expect.fail('Should have thrown an error for unauthorized move'); } catch (err) { expect(err).to.be.instanceOf(Error); expect((err as Error).message).to.include( 'does not have write permission' ); } // Verify user was not moved const searchResult = await dm.ldap.search( { paged: false, scope: 'base', filter: '(objectClass=*)', }, getTestUser1Dn() ); expect((searchResult as any).searchEntries).to.have.lengthOf(1); expect( (searchResult as any).searchEntries[0].twakeDepartmentLink ).to.equal(getTestOrgDn()); // Still in original org }); it('should reject moving user from unauthorized org (requires read on source)', async () => { // Create admin user const adminEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), adminEntry); // Create organization 1 without admin (user starts here, unauthorized) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', }; await dm.ldap.add(getTestOrgDn(), org1Entry); // Create organization 2 with admin (authorized for admin) const org2Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg2', twakeDepartmentPath: 'TestOrg2 / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrg2Dn(), org2Entry); // Create user in first organization (where admin has no rights) const userEntry = { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], uid: 'testuser1', sn: 'User1', cn: 'Test User 1', twakeDepartmentLink: getTestOrgDn(), twakeDepartmentPath: 'TestOrg / organization', }; await dm.ldap.add(getTestUser1Dn(), userEntry); // Create mock request const mockReq = { user: 'testadmin' } as any; // Try to move user to authorized organization // This should fail because admin does not have read permission on source org try { await dm.ldap.modify( getTestUser1Dn(), { replace: { twakeDepartmentLink: getTestOrg2Dn(), twakeDepartmentPath: 'TestOrg2 / organization', }, }, mockReq ); expect.fail('Should have thrown an error for unauthorized move'); } catch (err) { expect(err).to.be.instanceOf(Error); expect((err as Error).message).to.include( 'does not have read permission for source branch' ); } // Verify user was not moved const searchResult = await dm.ldap.search( { paged: false, scope: 'base', filter: '(objectClass=*)', }, getTestUser1Dn() ); expect((searchResult as any).searchEntries).to.have.lengthOf(1); expect( (searchResult as any).searchEntries[0].twakeDepartmentLink ).to.equal(getTestOrgDn()); // Still in original org }); }); describe('WRITE - Add user with twakeDepartmentLink', () => { const getTestUser1Dn = () => `uid=testuser1,ou=users,${process.env.DM_LDAP_BASE}`; const getTestUser2Dn = () => `uid=testuser2,ou=users,${process.env.DM_LDAP_BASE}`; afterEach(async () => { try { await dm.ldap.delete(getTestUser1Dn()); } catch (err) { // Ignore if doesn't exist } try { await dm.ldap.delete(getTestUser2Dn()); } catch (err) { // Ignore if doesn't exist } }); it('should allow adding a user in ou=users if twakeDepartmentLink points to authorized org', async () => { // Create admin user const adminEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), adminEntry); // Create organization with admin (authorized) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), org1Entry); // Admin should NOT have direct write permission on ou=users const usersBranchDn = `ou=users,${process.env.DM_LDAP_BASE}`; const usersBranchPerms = await authz.getUserPermissions( getTestUserDn(), usersBranchDn ); expect(usersBranchPerms.write).to.be.false; // But admin SHOULD be able to create a user with twakeDepartmentLink pointing to their org // This is done via direct LDAP add with req context const newUserEntry = { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], uid: 'testuser1', sn: 'User1', cn: 'Test User 1', twakeDepartmentLink: [getTestOrgDn()], // Points to authorized org (array format) }; // Create mock request const mockReq = { user: 'testadmin' } as any; // This should succeed because twakeDepartmentLink points to authorized org await dm.ldap.add(getTestUser1Dn(), newUserEntry, mockReq); // Verify it was written to LDAP const searchResult = await dm.ldap.search( { paged: false, scope: 'base', filter: '(objectClass=*)', }, getTestUser1Dn() ); expect((searchResult as any).searchEntries).to.have.lengthOf(1); expect((searchResult as any).searchEntries[0].uid).to.equal( 'testuser1' ); expect( (searchResult as any).searchEntries[0].twakeDepartmentLink ).to.equal(getTestOrgDn()); }); it('should reject adding a user in ou=users if twakeDepartmentLink points to unauthorized org', async () => { // Create admin user const adminEntry = { objectClass: ['top', 'inetOrgPerson'], uid: 'testadmin', sn: 'Admin', cn: 'Test Admin', }; await dm.ldap.add(getTestUserDn(), adminEntry); // Create organization 1 with admin (authorized) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', twakeLocalAdminLink: getTestUserDn(), }; await dm.ldap.add(getTestOrgDn(), org1Entry); // Create organization 2 without admin (unauthorized) const org2Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg2', twakeDepartmentPath: 'TestOrg2 / organization', }; await dm.ldap.add(getTestOrg2Dn(), org2Entry); // Try to create a user with twakeDepartmentLink pointing to unauthorized org const newUserEntry = { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], uid: 'testuser2', sn: 'User2', cn: 'Test User 2', twakeDepartmentLink: [getTestOrg2Dn()], // Points to UNAUTHORIZED org (array format) }; // Create mock request const mockReq = { user: 'testadmin' } as any; // This should be rejected try { await dm.ldap.add(getTestUser2Dn(), newUserEntry, mockReq); expect.fail('Should have thrown an error for unauthorized write'); } catch (err) { expect(err).to.be.instanceOf(Error); expect((err as Error).message).to.include( 'does not have write permission' ); } // Verify nothing was written to LDAP try { await dm.ldap.search( { paged: false, scope: 'base', filter: '(objectClass=*)', }, getTestUser2Dn() ); expect.fail('User should not have been created'); } catch (err) { expect(err).to.be.instanceOf(Error); expect((err as any).code).to.equal(32); // NoSuchObject } }); }); }); }); linagora-ldap-rest-16e557e/test/plugins/auth/authzMoveGroups.test.ts000066400000000000000000000147261522642357000256730ustar00rootroot00000000000000import { expect } from 'chai'; import AuthzPerBranch from '../../../src/plugins/auth/authzPerBranch'; import { DM } from '../../../src/bin'; import LdapGroups from '../../../src/plugins/ldap/groups'; import AuthBase, { type DmRequest } from '../../../src/lib/auth/base'; import type { Response } from 'express'; import type { Role } from '../../../src/abstract/plugin'; import supertest from 'supertest'; import { skipIfMissingEnvVars, LDAP_ENV_VARS_WITH_ORG, } from '../../helpers/env'; import type { SearchResult } from 'ldapts'; // Simple auth plugin for testing that sets user from X-Test-User header class TestAuthPlugin extends AuthBase { name = 'testAuth'; roles: Role[] = ['auth'] as const; authMethod(req: DmRequest, res: Response, next: () => void): void { const testUser = req.headers['x-test-user']; if (testUser && typeof testUser === 'string') { req.user = testUser; return next(); } res.status(401).json({ error: 'Unauthorized' }); } } describe('Authorization for Group Move', function () { before(function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS_WITH_ORG]); }); let server: DM; let authzPlugin: AuthzPerBranch; let groupsPlugin: LdapGroups; let request: any; const getOrg1Dn = () => `ou=testorg1,${process.env.DM_LDAP_TOP_ORGANIZATION}`; const getOrg2Dn = () => `ou=testorg2,${process.env.DM_LDAP_TOP_ORGANIZATION}`; const getGroupDn = () => `cn=testgroup,${process.env.DM_LDAP_GROUP_BASE}`; before(async function () { this.timeout(10000); // Create test config with authorization rules const testConfig = { default: { read: false, write: false, delete: false, }, users: { testuser1: { // Has read access to org1 and write access to org2 [getOrg1Dn()]: { read: true, write: false, delete: false, }, [getOrg2Dn()]: { read: true, write: true, delete: false, }, }, testuser2: { // Has write access to org1 but no access to org2 [getOrg1Dn()]: { read: true, write: true, delete: false, }, }, testuser3: { // Has read/write access to both orgs [getOrg1Dn()]: { read: true, write: true, delete: false, }, [getOrg2Dn()]: { read: true, write: true, delete: false, }, }, testuser4: { // Has read access to org1 but no write access to org2 [getOrg1Dn()]: { read: true, write: false, delete: false, }, [getOrg2Dn()]: { read: true, write: false, delete: false, }, }, }, groups: {}, }; // Set environment variables process.env.DM_AUTHZ_PER_BRANCH_CONFIG = JSON.stringify(testConfig); process.env.DM_AUTHZ_PER_BRANCH_CACHE_TTL = '60'; // Initialize server server = new DM(); // Register auth plugin const authPlugin = new TestAuthPlugin(server); await server.registerPlugin('testAuth', authPlugin); // Register authz plugin authzPlugin = new AuthzPerBranch(server); await server.registerPlugin('authzPerBranch', authzPlugin); // Register groups plugin groupsPlugin = new LdapGroups(server); await server.registerPlugin('ldapGroups', groupsPlugin); // Setup API authPlugin.api(server.app); groupsPlugin.api(server.app); request = supertest(server.app); // Create test organizations await server.ldap.add(getOrg1Dn(), { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'testorg1', twakeDepartmentPath: 'Test Org 1', }); await server.ldap.add(getOrg2Dn(), { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'testorg2', twakeDepartmentPath: 'Test Org 2', }); }); after(async () => { try { await server.ldap.delete(getOrg1Dn()); } catch (e) { // ignore } try { await server.ldap.delete(getOrg2Dn()); } catch (e) { // ignore } }); beforeEach(async () => { // Create test group in org1 await server.ldap.add(getGroupDn(), { objectClass: ['groupOfNames', 'twakeStaticGroup', 'top'], cn: 'testgroup', member: ['cn=fakeuser'], twakeDepartmentLink: getOrg1Dn(), twakeDepartmentPath: 'Test Org 1', }); }); afterEach(async () => { try { await server.ldap.delete(getGroupDn()); } catch (e) { // ignore } }); it('should allow move when user has read access to source and write access to destination', async () => { const res = await request .post('/api/v1/ldap/groups/testgroup/move') .set('X-Test-User', 'testuser1') .type('json') .send({ targetOrgDn: getOrg2Dn(), }); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ success: true }); // Verify group was moved const group = await server.ldap.search( { paged: false, scope: 'base' }, getGroupDn() ); expect( (group as SearchResult).searchEntries[0].twakeDepartmentLink ).to.equal(getOrg2Dn()); }); it('should reject move when user lacks read access to source', async () => { const res = await request .post('/api/v1/ldap/groups/testgroup/move') .set('X-Test-User', 'testuser2') .type('json') .send({ targetOrgDn: getOrg2Dn(), }); expect(res.status).to.equal(403); expect(res.body.error).to.equal( 'Token does not have permission on this branch' ); }); it('should reject move when user lacks write access to destination', async () => { const res = await request .post('/api/v1/ldap/groups/testgroup/move') .set('X-Test-User', 'testuser4') .type('json') .send({ targetOrgDn: getOrg2Dn(), }); expect(res.status).to.equal(403); expect(res.body.error).to.equal( 'Token does not have permission on this branch' ); }); it('should allow move when user has full access', async () => { const res = await request .post('/api/v1/ldap/groups/testgroup/move') .set('X-Test-User', 'testuser3') .type('json') .send({ targetOrgDn: getOrg2Dn(), }); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ success: true }); }); }); linagora-ldap-rest-16e557e/test/plugins/auth/authzMoveOrganizations.test.ts000066400000000000000000000172641522642357000272430ustar00rootroot00000000000000import { expect } from 'chai'; import AuthzPerBranch from '../../../src/plugins/auth/authzPerBranch'; import { DM } from '../../../src/bin'; import LdapOrganizations from '../../../src/plugins/ldap/organizations'; import AuthBase, { type DmRequest } from '../../../src/lib/auth/base'; import type { Response } from 'express'; import type { Role } from '../../../src/abstract/plugin'; import supertest from 'supertest'; import { skipIfMissingEnvVars, LDAP_ENV_VARS_WITH_ORG, } from '../../helpers/env'; import type { SearchResult } from 'ldapts'; // Simple auth plugin for testing that sets user from X-Test-User header class TestAuthPlugin extends AuthBase { name = 'testAuth'; roles: Role[] = ['auth'] as const; authMethod(req: DmRequest, res: Response, next: () => void): void { const testUser = req.headers['x-test-user']; if (testUser && typeof testUser === 'string') { req.user = testUser; return next(); } res.status(401).json({ error: 'Unauthorized' }); } } describe('Authorization for Organization Move', function () { before(function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS_WITH_ORG]); }); let server: DM; let authzPlugin: AuthzPerBranch; let orgPlugin: LdapOrganizations; let request: any; const getParentOrg1Dn = () => `ou=parent1,${process.env.DM_LDAP_TOP_ORGANIZATION}`; const getParentOrg2Dn = () => `ou=parent2,${process.env.DM_LDAP_TOP_ORGANIZATION}`; const getChildOrgDn = () => `ou=child,${getParentOrg1Dn()}`; before(async function () { this.timeout(10000); // Create test config with authorization rules const testConfig = { default: { read: false, write: false, delete: false, }, users: { testuser1: { // Has read access to parent1 and write access to parent2 [getParentOrg1Dn()]: { read: true, write: false, delete: false, }, [getParentOrg2Dn()]: { read: true, write: true, delete: false, }, }, testuser2: { // Has write access to parent1 but no access to parent2 [getParentOrg1Dn()]: { read: true, write: true, delete: false, }, }, testuser3: { // Has read/write access to both parents [getParentOrg1Dn()]: { read: true, write: true, delete: false, }, [getParentOrg2Dn()]: { read: true, write: true, delete: false, }, }, }, groups: {}, }; // Set environment variables process.env.DM_AUTHZ_PER_BRANCH_CONFIG = JSON.stringify(testConfig); process.env.DM_AUTHZ_PER_BRANCH_CACHE_TTL = '60'; // Initialize server server = new DM(); // Register auth plugin const authPlugin = new TestAuthPlugin(server); await server.registerPlugin('testAuth', authPlugin); // Register authz plugin authzPlugin = new AuthzPerBranch(server); await server.registerPlugin('authzPerBranch', authzPlugin); // Register organizations plugin orgPlugin = new LdapOrganizations(server); await server.registerPlugin('ldapOrganizations', orgPlugin); // Setup API authPlugin.api(server.app); orgPlugin.api(server.app); request = supertest(server.app); // Clean up any existing test organizations first (including children) const cleanupOrg = async (dn: string) => { try { // Search for children first const result: SearchResult = (await server.ldap.search( { scope: 'one', filter: '(objectClass=*)', paged: false }, dn )) as SearchResult; // Delete children first for (const entry of result.searchEntries) { await cleanupOrg(entry.dn); } // Then delete the parent await server.ldap.delete(dn); } catch (e) { // ignore if doesn't exist } }; await cleanupOrg(getParentOrg1Dn()); await cleanupOrg(getParentOrg2Dn()); // Create test parent organizations await server.ldap.add(getParentOrg1Dn(), { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'parent1', }); await server.ldap.add(getParentOrg2Dn(), { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'parent2', }); }); after(async () => { try { await server.ldap.delete(getParentOrg1Dn()); } catch (e) { // ignore } try { await server.ldap.delete(getParentOrg2Dn()); } catch (e) { // ignore } }); beforeEach(async () => { // Create test child organization under parent1 await server.ldap.add(getChildOrgDn(), { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'child', twakeDepartmentPath: 'child / parent1', }); }); afterEach(async () => { try { // Try both possible locations await server.ldap.delete(getChildOrgDn()); } catch (e) { // ignore } try { const movedChildDn = `ou=child,${getParentOrg2Dn()}`; await server.ldap.delete(movedChildDn); } catch (e) { // ignore } }); it('should allow move when user has read access to source and write access to destination', async () => { const res = await request .post( `/api/v1/ldap/organizations/${encodeURIComponent(getChildOrgDn())}/move` ) .set('X-Test-User', 'testuser1') .type('json') .send({ targetOrgDn: getParentOrg2Dn(), }); expect(res.status).to.equal(200); expect(res.body).to.have.property('newDn'); // Verify organization was moved const movedChildDn = `ou=child,${getParentOrg2Dn()}`; const org = await server.ldap.search( { paged: false, scope: 'base' }, movedChildDn ); expect((org as SearchResult).searchEntries[0].dn).to.equal(movedChildDn); }); it('should reject move when user lacks read access to source', async () => { const res = await request .post( `/api/v1/ldap/organizations/${encodeURIComponent(getChildOrgDn())}/move` ) .set('X-Test-User', 'testuser2') .type('json') .send({ targetOrgDn: getParentOrg2Dn(), }); expect(res.status).to.equal(403); expect(res.body.error).to.equal( 'Token does not have permission on this branch' ); }); it('should reject move when user lacks write access to destination', async () => { // testuser1 has read on parent1 but no write, so moving within parent1 should fail const siblingOrgDn = `ou=sibling,${getParentOrg1Dn()}`; await server.ldap.add(siblingOrgDn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'sibling', twakeDepartmentPath: 'sibling / parent1', }); try { const res = await request .post( `/api/v1/ldap/organizations/${encodeURIComponent(getChildOrgDn())}/move` ) .set('X-Test-User', 'testuser1') .type('json') .send({ targetOrgDn: siblingOrgDn, }); expect(res.status).to.equal(403); expect(res.body.error).to.equal( 'Token does not have permission on this branch' ); } finally { await server.ldap.delete(siblingOrgDn); } }); it('should allow move when user has full access', async () => { const res = await request .post( `/api/v1/ldap/organizations/${encodeURIComponent(getChildOrgDn())}/move` ) .set('X-Test-User', 'testuser3') .type('json') .send({ targetOrgDn: getParentOrg2Dn(), }); expect(res.status).to.equal(200); expect(res.body).to.have.property('newDn'); }); }); linagora-ldap-rest-16e557e/test/plugins/auth/authzPerBranch.test.ts000066400000000000000000000504241522642357000254240ustar00rootroot00000000000000import { expect } from 'chai'; import AuthzPerBranch from '../../../src/plugins/auth/authzPerBranch'; import { DM } from '../../../src/bin'; import LdapOrganization from '../../../src/plugins/ldap/organizations'; import AuthBase, { type DmRequest } from '../../../src/lib/auth/base'; import type { Response } from 'express'; import type { Role } from '../../../src/abstract/plugin'; import supertest from 'supertest'; import { skipIfMissingEnvVars, LDAP_ENV_VARS_WITH_ORG, } from '../../helpers/env'; // Simple auth plugin for testing that sets user from X-Test-User header class TestAuthPlugin extends AuthBase { name = 'testAuth'; roles: Role[] = ['auth'] as const; authMethod(req: DmRequest, res: Response, next: () => void): void { // Use X-Test-User header to identify user, or fall back to token-based auth const testUser = req.headers['x-test-user']; if (testUser && typeof testUser === 'string') { req.user = testUser; return next(); } // Otherwise, require a valid token let token = req.headers['authorization']; if (!token || !/^Bearer .+/.test(token)) { res.status(401).json({ error: 'Unauthorized' }); return; } token = token.split(' ')[1]; if (!(this.config.auth_token as string[]).includes(token)) { res.status(401).json({ error: 'Unauthorized' }); return; } req.user = 'token number ' + (this.config.auth_token as string[]).indexOf(token); next(); } } // Use getters to ensure env vars are evaluated after setup const getUserBranch = () => `ou=users,${process.env.DM_LDAP_BASE}`; describe('AuthzPerBranch', function () { before(function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS_WITH_ORG]); }); let server: DM; let plugin: AuthzPerBranch; before(async function () { this.timeout(5000); // Create test config as JSON string for environment variable const testConfig = { default: { read: false, write: false, delete: false, }, users: { testuser1: { [getUserBranch() as string]: { read: true, write: true, delete: false, }, }, testuser2: { [getUserBranch() as string]: { read: true, write: false, delete: false, }, }, }, groups: {}, }; // Set environment variables BEFORE creating DM process.env.DM_AUTHZ_PER_BRANCH_CONFIG = JSON.stringify(testConfig); process.env.DM_AUTHZ_PER_BRANCH_CACHE_TTL = '60'; // Initialize server with config server = new DM(); plugin = new AuthzPerBranch(server); await server.registerPlugin('authzPerBranch', plugin); }); describe('Config loading', () => { it('should load authorization config from environment', () => { expect(plugin.authConfig).to.exist; expect(plugin.authConfig?.default).to.deep.equal({ read: false, write: false, delete: false, }); expect(plugin.authConfig?.users).to.have.property('testuser1'); expect(plugin.authConfig?.users).to.have.property('testuser2'); }); it('should set cache TTL from config', () => { expect(plugin.cacheTTL).to.equal(60000); // 60 seconds in ms }); }); describe('Permission checking', () => { it('should return default permissions for unknown user', async function () { this.timeout(5000); const permissions = await plugin.getUserPermissions( 'unknownuser', getUserBranch() as string ); expect(permissions).to.deep.equal({ read: false, write: false, delete: false, }); }); it('should return user-specific permissions', async function () { this.timeout(5000); const permissions = await plugin.getUserPermissions( 'testuser1', getUserBranch() as string ); expect(permissions.read).to.be.true; expect(permissions.write).to.be.true; expect(permissions.delete).to.be.false; }); it('should return different permissions for different users', async function () { this.timeout(5000); const permissions = await plugin.getUserPermissions( 'testuser2', getUserBranch() as string ); expect(permissions.read).to.be.true; expect(permissions.write).to.be.false; expect(permissions.delete).to.be.false; }); it('should support sub-branch permissions', async function () { this.timeout(5000); // Test that permissions apply to sub-branches const subBranch = `ou=test,${getUserBranch()}`; const permissions = await plugin.getUserPermissions( 'testuser1', subBranch ); expect(permissions.read).to.be.true; expect(permissions.write).to.be.true; }); }); describe('Authorized branches', () => { it('should return authorized branches for read permission', async function () { this.timeout(5000); const branches = await plugin.getAuthorizedBranchesForPermission( 'testuser1', 'read' ); expect(branches).to.be.an('array'); expect(branches).to.include(getUserBranch()); }); it('should return authorized branches for write permission', async function () { this.timeout(5000); const branches = await plugin.getAuthorizedBranchesForPermission( 'testuser1', 'write' ); expect(branches).to.be.an('array'); expect(branches).to.include(getUserBranch()); }); it('should return empty array for unauthorized permission', async function () { this.timeout(5000); const branches = await plugin.getAuthorizedBranchesForPermission( 'testuser2', 'write' ); expect(branches).to.be.an('array'); expect(branches).to.have.lengthOf(0); }); it('should return empty array for unknown user', async function () { this.timeout(5000); const branches = await plugin.getAuthorizedBranchesForPermission( 'unknownuser', 'read' ); expect(branches).to.be.an('array'); expect(branches).to.have.lengthOf(0); }); }); describe('Group caching', () => { it('should cache group memberships', async function () { this.timeout(5000); const uid = 'testuser1'; // First call - should query LDAP const groups1 = await plugin.getUserGroups(uid); // Second call - should use cache const groups2 = await plugin.getUserGroups(uid); expect(groups1).to.deep.equal(groups2); expect(plugin.groupCache.has(uid)).to.be.true; }); it('should expire cache after TTL', async function () { this.timeout(5000); const uid = 'testuser_cache_test'; // Set a very short TTL for this test const originalTTL = plugin.cacheTTL; plugin.cacheTTL = 100; // 100ms // First call await plugin.getUserGroups(uid); expect(plugin.groupCache.has(uid)).to.be.true; // Wait for cache to expire await new Promise(resolve => setTimeout(resolve, 150)); // Second call should trigger new LDAP query await plugin.getUserGroups(uid); // Restore original TTL plugin.cacheTTL = originalTTL; }); }); describe('Hook integration', () => { it('should register ldapsearchrequest hook', () => { expect(plugin.hooks).to.not.be.undefined; expect(plugin.hooks).to.have.property('ldapsearchrequest'); expect(plugin.hooks?.ldapsearchrequest).to.be.a('function'); }); it('should throw error when user lacks read permission', async function () { this.timeout(5000); try { // Create a mock request with unauthorized user const mockReq = { user: 'unknownuser' } as any; if (plugin.hooks?.ldapsearchrequest) { await plugin.hooks.ldapsearchrequest([ getUserBranch() as string, { paged: false }, mockReq, ]); } expect.fail('Should have thrown an error'); } catch (err: any) { expect(err.message).to.match(/does not have read permission/i); } }); it('should allow search when user has read permission', async function () { this.timeout(5000); const mockReq = { user: 'testuser1' } as any; if (plugin.hooks?.ldapsearchrequest) { const result = await plugin.hooks.ldapsearchrequest([ getUserBranch() as string, { paged: false }, mockReq, ]); expect(result[0]).to.equal(getUserBranch()); // When searching within authorized branch, filter should not be modified expect(result[1]).to.not.be.undefined; } }); it('should pass through when no user in request', async function () { this.timeout(5000); const mockReq = {} as any; if (plugin.hooks?.ldapsearchrequest) { const result = await plugin.hooks.ldapsearchrequest([ getUserBranch() as string, { paged: false }, mockReq, ]); expect(result[0]).to.equal(getUserBranch()); expect(result[1]).to.deep.equal({ paged: false }); } }); }); describe('API access control', () => { const getTestOrgDn = () => `ou=TestOrg,${process.env.DM_LDAP_TOP_ORGANIZATION}`; const getTestOrg2Dn = () => `ou=TestOrg2,${process.env.DM_LDAP_TOP_ORGANIZATION}`; const getTestSubOrg1Dn = () => `ou=SubOrg1,${getTestOrgDn()}`; const getTestSubOrg2Dn = () => `ou=SubOrg2,${getTestOrg2Dn()}`; const getTestUser1Dn = () => `uid=testuser1,ou=users,${process.env.DM_LDAP_BASE}`; const getTestUser2Dn = () => `uid=testuser2,ou=users,${process.env.DM_LDAP_BASE}`; const adminToken = 'test-admin-token'; let request: ReturnType; let orgPlugin: LdapOrganization; let authPlugin: TestAuthPlugin; let apiServer: DM; before(async function () { this.timeout(10000); // Create test config for API tests const testConfig = { default: { read: false, write: false, delete: false, }, users: { testuser1: { [getTestOrgDn()]: { read: true, write: true, delete: false, }, }, }, groups: {}, }; // Setup DM with auth and organization plugins process.env.DM_AUTH_TOKENS = adminToken; process.env.DM_AUTHZ_PER_BRANCH_CONFIG = JSON.stringify(testConfig); apiServer = new DM(); await apiServer.ready; // Register plugins const authzPerBranch = new AuthzPerBranch(apiServer); authPlugin = new TestAuthPlugin(apiServer); orgPlugin = new LdapOrganization(apiServer); await apiServer.registerPlugin('testAuth', authPlugin); await apiServer.registerPlugin('authzPerBranch', authzPerBranch); await apiServer.registerPlugin('ldapOrganizations', orgPlugin); orgPlugin.api(apiServer.app); request = supertest(apiServer.app); }); afterEach(async function () { this.timeout(5000); // Clean up test entries try { await apiServer.ldap.delete(getTestUser1Dn()); } catch (err) { // Ignore } try { await apiServer.ldap.delete(getTestUser2Dn()); } catch (err) { // Ignore } try { await apiServer.ldap.delete(getTestSubOrg1Dn()); } catch (err) { // Ignore } try { await apiServer.ldap.delete(getTestSubOrg2Dn()); } catch (err) { // Ignore } try { await apiServer.ldap.delete(getTestOrgDn()); } catch (err) { // Ignore } try { await apiServer.ldap.delete(getTestOrg2Dn()); } catch (err) { // Ignore } }); describe('READ - Search outside authorized scope', () => { it('should not allow search in unauthorized branch', async function () { this.timeout(5000); // Create organization 1 (authorized for testuser1) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', }; await apiServer.ldap.add(getTestOrgDn(), org1Entry); // Create organization 2 (NOT authorized) const org2Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg2', twakeDepartmentPath: 'TestOrg2 / organization', }; await apiServer.ldap.add(getTestOrg2Dn(), org2Entry); // Try to get unauthorized org via API - should fail const res = await request .get( `/api/v1/ldap/organizations/${encodeURIComponent(getTestOrg2Dn())}` ) .set('Authorization', `Bearer ${adminToken}`) .set('X-Test-User', 'testuser1') .set('Accept', 'application/json'); expect(res.status).to.equal(403); expect(res.body).to.have.property('error'); expect(res.body.error).to.equal( 'Token does not have permission on this branch' ); }); it('should allow search in authorized branch', async function () { this.timeout(5000); // Create organization 1 (authorized for testuser1) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', }; await apiServer.ldap.add(getTestOrgDn(), org1Entry); // Try to get authorized org via API - should succeed const res = await request .get( `/api/v1/ldap/organizations/${encodeURIComponent(getTestOrgDn())}` ) .set('Authorization', `Bearer ${adminToken}`) .set('X-Test-User', 'testuser1') .set('Accept', 'application/json'); expect(res.status).to.equal(200); expect(res.body).to.have.property('dn', getTestOrgDn()); expect(res.body).to.have.property('ou', 'TestOrg'); }); }); describe('WRITE - Add organizational unit outside scope', () => { it('should reject adding a sub-organization in an unauthorized branch', async function () { this.timeout(5000); // Create organization 1 (authorized) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', }; await apiServer.ldap.add(getTestOrgDn(), org1Entry); // Create organization 2 (unauthorized) const org2Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg2', twakeDepartmentPath: 'TestOrg2 / organization', }; await apiServer.ldap.add(getTestOrg2Dn(), org2Entry); // Try to add a sub-org under unauthorized org2 via API const res = await request .post('/api/v1/ldap/organizations') .set('Authorization', `Bearer ${adminToken}`) .set('X-Test-User', 'testuser1') .type('json') .send({ ou: 'SubOrg2', parentDn: getTestOrg2Dn(), }); // Should be rejected expect(res.status).to.not.equal(200); // Verify nothing was written to LDAP try { await apiServer.ldap.search( { paged: false, scope: 'base', filter: '(objectClass=*)', }, getTestSubOrg2Dn() ); expect.fail('SubOrg2 should not have been created'); } catch (err) { expect(err).to.be.instanceOf(Error); expect((err as any).code).to.equal(32); // NoSuchObject } }); it('should allow adding a sub-organization in an authorized branch', async function () { this.timeout(5000); // Create organization 1 (authorized) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', }; await apiServer.ldap.add(getTestOrgDn(), org1Entry); // Try to add a sub-org under authorized org1 via API const res = await request .post('/api/v1/ldap/organizations') .set('Authorization', `Bearer ${adminToken}`) .set('X-Test-User', 'testuser1') .type('json') .send({ ou: 'SubOrg1', parentDn: getTestOrgDn(), }); // Should succeed expect(res.status).to.equal(200); expect(res.body).to.have.property('success', true); // Verify it was written to LDAP const searchResult = await apiServer.ldap.search( { paged: false, scope: 'base', filter: '(objectClass=*)', }, getTestSubOrg1Dn() ); expect((searchResult as any).searchEntries).to.have.lengthOf(1); expect((searchResult as any).searchEntries[0].ou).to.equal('SubOrg1'); }); }); describe('WRITE - Add user with twakeDepartmentLink', () => { it('should allow adding a user if twakeDepartmentLink points to authorized org', async function () { this.timeout(5000); // Create organization (authorized) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', }; await apiServer.ldap.add(getTestOrgDn(), org1Entry); // Create user with twakeDepartmentLink pointing to authorized org const newUserEntry = { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], uid: 'testuser1', sn: 'User1', cn: 'Test User 1', twakeDepartmentLink: [getTestOrgDn()], }; const mockReq = { user: 'testuser1' } as any; // This should succeed await apiServer.ldap.add(getTestUser1Dn(), newUserEntry, mockReq); // Verify it was written const searchResult = await apiServer.ldap.search( { paged: false, scope: 'base', filter: '(objectClass=*)', }, getTestUser1Dn() ); expect((searchResult as any).searchEntries).to.have.lengthOf(1); expect((searchResult as any).searchEntries[0].uid).to.equal( 'testuser1' ); }); it('should reject adding a user if twakeDepartmentLink points to unauthorized org', async function () { this.timeout(5000); // Create organization 1 (authorized) const org1Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'TestOrg / organization', }; await apiServer.ldap.add(getTestOrgDn(), org1Entry); // Create organization 2 (unauthorized) const org2Entry = { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg2', twakeDepartmentPath: 'TestOrg2 / organization', }; await apiServer.ldap.add(getTestOrg2Dn(), org2Entry); // Try to create user with twakeDepartmentLink pointing to unauthorized org const newUserEntry = { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], uid: 'testuser2', sn: 'User2', cn: 'Test User 2', twakeDepartmentLink: [getTestOrg2Dn()], // UNAUTHORIZED }; const mockReq = { user: 'testuser1' } as any; // This should be rejected try { await apiServer.ldap.add(getTestUser2Dn(), newUserEntry, mockReq); expect.fail('Should have thrown an error for unauthorized write'); } catch (err) { expect(err).to.be.instanceOf(Error); expect((err as Error).message).to.include( 'does not have write permission' ); } // Verify nothing was written try { await apiServer.ldap.search( { paged: false, scope: 'base', filter: '(objectClass=*)', }, getTestUser2Dn() ); expect.fail('User should not have been created'); } catch (err) { expect(err).to.be.instanceOf(Error); expect((err as any).code).to.equal(32); // NoSuchObject } }); }); }); }); linagora-ldap-rest-16e557e/test/plugins/auth/authzPerRoute.test.ts000066400000000000000000000331451522642357000253260ustar00rootroot00000000000000import { DM } from '../../../src/bin'; import type { Express } from 'express'; import request from 'supertest'; import AuthToken from '../../../src/plugins/auth/token'; import AuthzPerRoute, { globToRegex, } from '../../../src/plugins/auth/authzPerRoute'; import HelloWorld from '../../../src/plugins/demo/helloworld'; import { expect } from 'chai'; describe('AuthzPerRoute', () => { describe('Basic rule enforcement', () => { let app: Express; before(async () => { // full:tok-full → wildcard; updt:tok-updt → GET /api/hello only process.env.DM_AUTH_TOKENS = 'tok-full:full,tok-updt:updt'; process.env.DM_AUTHZ_PER_ROUTES = 'full:*,updt:GET:/api/hello'; const dm = new DM(); await dm.ready; // Auth must be registered before authz await dm.registerPlugin('authToken', new AuthToken(dm)); await dm.registerPlugin('authzPerRoute', new AuthzPerRoute(dm)); await dm.registerPlugin('helloWorld', new HelloWorld(dm)); app = dm.app; }); after(() => { delete process.env.DM_AUTH_TOKENS; delete process.env.DM_AUTHZ_PER_ROUTES; }); it('wildcard user can reach /api/hello', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer tok-full'); expect(res.status).to.equal(200); }); it('restricted user with matching GET rule → 200', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer tok-updt'); expect(res.status).to.equal(200); }); it('restricted user with non-matching method → 403', async () => { const res = await request(app) .post('/api/hello') .set('Authorization', 'Bearer tok-updt'); // POST is not in the rule for updt user; Express may return 404 for // unregistered POST route, but our authz middleware runs first // and should deny it with 403 before the route handler. expect(res.status).to.equal(403); }); }); describe('Unknown user (authenticated but not in authz config)', () => { let app: Express; before(async () => { process.env.DM_AUTH_TOKENS = 'tok-known:known,tok-unknown:unknown'; process.env.DM_AUTHZ_PER_ROUTES = 'known:*'; const dm = new DM(); await dm.ready; await dm.registerPlugin('authToken', new AuthToken(dm)); await dm.registerPlugin('authzPerRoute', new AuthzPerRoute(dm)); await dm.registerPlugin('helloWorld', new HelloWorld(dm)); app = dm.app; }); after(() => { delete process.env.DM_AUTH_TOKENS; delete process.env.DM_AUTHZ_PER_ROUTES; }); it('known user → 200', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer tok-known'); expect(res.status).to.equal(200); }); it('authenticated but unknown-to-authz user → 403', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer tok-unknown'); expect(res.status).to.equal(403); }); }); describe('No auth plugin (req.user unset)', () => { let app: Express; before(async () => { delete process.env.DM_AUTH_TOKENS; process.env.DM_AUTHZ_PER_ROUTES = 'someuser:*'; const dm = new DM(); await dm.ready; // Only authz + helloWorld, no auth plugin → req.user stays unset await dm.registerPlugin('authzPerRoute', new AuthzPerRoute(dm)); await dm.registerPlugin('helloWorld', new HelloWorld(dm)); app = dm.app; }); after(() => { delete process.env.DM_AUTHZ_PER_ROUTES; }); it('unauthenticated request passes through authz middleware → 200', async () => { const res = await request(app).get('/api/hello'); expect(res.status).to.equal(200); }); }); describe('Invalid rule entries are ignored (no crash)', () => { let app: Express; before(async () => { process.env.DM_AUTH_TOKENS = 'tok-full:full'; // "badentry" has no colon → invalid and should be skipped process.env.DM_AUTHZ_PER_ROUTES = 'badentry,full:*'; const dm = new DM(); await dm.ready; await dm.registerPlugin('authToken', new AuthToken(dm)); await dm.registerPlugin('authzPerRoute', new AuthzPerRoute(dm)); await dm.registerPlugin('helloWorld', new HelloWorld(dm)); app = dm.app; }); after(() => { delete process.env.DM_AUTH_TOKENS; delete process.env.DM_AUTHZ_PER_ROUTES; }); it('still starts and valid rules work', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer tok-full'); expect(res.status).to.equal(200); }); }); describe('Method wildcard rule (*)', () => { let app: Express; before(async () => { process.env.DM_AUTH_TOKENS = 'tok-any:any'; // any method on /api/hello (exact glob — no wildcards in path) process.env.DM_AUTHZ_PER_ROUTES = 'any:*:/api/hello'; const dm = new DM(); await dm.ready; await dm.registerPlugin('authToken', new AuthToken(dm)); await dm.registerPlugin('authzPerRoute', new AuthzPerRoute(dm)); await dm.registerPlugin('helloWorld', new HelloWorld(dm)); app = dm.app; }); after(() => { delete process.env.DM_AUTH_TOKENS; delete process.env.DM_AUTHZ_PER_ROUTES; }); it('method wildcard * allows GET', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer tok-any'); expect(res.status).to.equal(200); }); }); describe('globToRegex — glob semantics', () => { describe('* (single segment wildcard)', () => { it('matches one path segment', () => { const re = globToRegex('/api/*'); expect(re.test('/api/hello')).to.equal(true); }); it('does NOT cross a slash (no multi-segment match)', () => { const re = globToRegex('/api/*'); expect(re.test('/api/hello/world')).to.equal(false); }); it('does NOT match the prefix without a segment', () => { const re = globToRegex('/api/*'); expect(re.test('/api')).to.equal(false); }); }); describe('** (multi-segment wildcard)', () => { it('matches a single segment', () => { const re = globToRegex('/api/**'); expect(re.test('/api/hello')).to.equal(true); }); it('crosses slashes (matches multiple segments)', () => { const re = globToRegex('/api/**'); expect(re.test('/api/hello/world')).to.equal(true); }); it('does NOT match the prefix alone (** must consume at least something)', () => { const re = globToRegex('/api/**'); expect(re.test('/api')).to.equal(false); }); }); describe('** appended directly to a prefix', () => { it('matches the exact prefix', () => { const re = globToRegex('/api/hello**'); expect(re.test('/api/hello')).to.equal(true); }); it('matches the prefix with sub-paths', () => { const re = globToRegex('/api/hello**'); expect(re.test('/api/hello/sub')).to.equal(true); expect(re.test('/api/hello/sub/deep')).to.equal(true); }); it('does NOT match a shorter string', () => { const re = globToRegex('/api/hello**'); expect(re.test('/api/hell')).to.equal(false); }); }); describe('regex metacharacters in glob are treated literally', () => { it('. matches only a literal dot, not any character', () => { const re = globToRegex('/api/hello.bak'); expect(re.test('/api/hello.bak')).to.equal(true); expect(re.test('/api/helloXbak')).to.equal(false); }); it('+ in pattern is literal', () => { const re = globToRegex('/path+extra'); expect(re.test('/path+extra')).to.equal(true); expect(re.test('/pathextra')).to.equal(false); }); }); describe('whitelist validation', () => { it('throws on semicolon in glob', () => { expect(() => globToRegex('foo;bar')).to.throw(/Invalid glob pattern/); }); it('throws on space in glob', () => { expect(() => globToRegex('foo bar')).to.throw(/Invalid glob pattern/); }); }); }); describe('Malformed glob in rule is ignored (no crash)', () => { let app: Express; before(async () => { process.env.DM_AUTH_TOKENS = 'tok-valid:validuser'; // "foo?bar" contains a question-mark — invalid glob, rule must be skipped. // Deliberately avoid ';' here because the config parser uses ';' as array // separator when present in the env-var value, which would mangle the entry. process.env.DM_AUTHZ_PER_ROUTES = 'validuser:GET:foo?bar,validuser:GET:/api/hello'; const dm = new DM(); await dm.ready; await dm.registerPlugin('authToken', new AuthToken(dm)); await dm.registerPlugin('authzPerRoute', new AuthzPerRoute(dm)); await dm.registerPlugin('helloWorld', new HelloWorld(dm)); app = dm.app; }); after(() => { delete process.env.DM_AUTH_TOKENS; delete process.env.DM_AUTHZ_PER_ROUTES; }); it('plugin starts and valid rule still works', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer tok-valid'); expect(res.status).to.equal(200); }); it('malformed-glob rule does not grant access (returns 403 on unmatched path)', async () => { const res = await request(app) .get('/some/other/path') .set('Authorization', 'Bearer tok-valid'); expect(res.status).to.equal(403); }); }); describe('parseEntry — trim and validation', () => { // These tests inject entries directly into dm.config to bypass the env-var // parser (which splits on whitespace). This simulates CLI usage where the // shell preserves spaces inside quoted arguments, e.g.: // --authz-per-route " user :*" it('entry with leading/trailing whitespace around user parses correctly (wildcard)', async () => { process.env.DM_AUTH_TOKENS = 'tok-ws:user'; process.env.DM_AUTHZ_PER_ROUTES = 'placeholder'; const dm = new DM(); await dm.ready; // Inject the whitespace-containing entry directly, bypassing env-var splitting (dm.config as Record).authz_per_route = [' user :*']; await dm.registerPlugin('authToken', new AuthToken(dm)); await dm.registerPlugin('authzPerRoute', new AuthzPerRoute(dm)); await dm.registerPlugin('helloWorld', new HelloWorld(dm)); const res = await request(dm.app) .get('/api/hello') .set('Authorization', 'Bearer tok-ws'); expect(res.status).to.equal(200); delete process.env.DM_AUTH_TOKENS; delete process.env.DM_AUTHZ_PER_ROUTES; }); it('entry with space before path trims correctly → 200', async () => { process.env.DM_AUTH_TOKENS = 'tok-sp:user'; process.env.DM_AUTHZ_PER_ROUTES = 'placeholder'; const dm = new DM(); await dm.ready; // Inject the entry with a space before the path (dm.config as Record).authz_per_route = [ 'user:GET: /api/hello', ]; await dm.registerPlugin('authToken', new AuthToken(dm)); await dm.registerPlugin('authzPerRoute', new AuthzPerRoute(dm)); await dm.registerPlugin('helloWorld', new HelloWorld(dm)); const res = await request(dm.app) .get('/api/hello') .set('Authorization', 'Bearer tok-sp'); expect(res.status).to.equal(200); delete process.env.DM_AUTH_TOKENS; delete process.env.DM_AUTHZ_PER_ROUTES; }); it('entry with unknown method is ignored → user has no rules → 403', async () => { process.env.DM_AUTH_TOKENS = 'tok-bogus:user'; process.env.DM_AUTHZ_PER_ROUTES = 'user:BOGUS:/api/hello'; const dm = new DM(); await dm.ready; await dm.registerPlugin('authToken', new AuthToken(dm)); await dm.registerPlugin('authzPerRoute', new AuthzPerRoute(dm)); await dm.registerPlugin('helloWorld', new HelloWorld(dm)); const res = await request(dm.app) .get('/api/hello') .set('Authorization', 'Bearer tok-bogus'); expect(res.status).to.equal(403); delete process.env.DM_AUTH_TOKENS; delete process.env.DM_AUTHZ_PER_ROUTES; }); it('entry with empty method after trim is ignored → 403', async () => { process.env.DM_AUTH_TOKENS = 'tok-em:user'; process.env.DM_AUTHZ_PER_ROUTES = 'user::/api/hello'; const dm = new DM(); await dm.ready; await dm.registerPlugin('authToken', new AuthToken(dm)); await dm.registerPlugin('authzPerRoute', new AuthzPerRoute(dm)); await dm.registerPlugin('helloWorld', new HelloWorld(dm)); const res = await request(dm.app) .get('/api/hello') .set('Authorization', 'Bearer tok-em'); expect(res.status).to.equal(403); delete process.env.DM_AUTH_TOKENS; delete process.env.DM_AUTHZ_PER_ROUTES; }); it('entry with empty user is ignored; other rules still apply', async () => { process.env.DM_AUTH_TOKENS = 'tok-eu:realuser'; process.env.DM_AUTHZ_PER_ROUTES = ':GET:/api/hello,realuser:*'; const dm = new DM(); await dm.ready; await dm.registerPlugin('authToken', new AuthToken(dm)); await dm.registerPlugin('authzPerRoute', new AuthzPerRoute(dm)); await dm.registerPlugin('helloWorld', new HelloWorld(dm)); const res = await request(dm.app) .get('/api/hello') .set('Authorization', 'Bearer tok-eu'); expect(res.status).to.equal(200); delete process.env.DM_AUTH_TOKENS; delete process.env.DM_AUTHZ_PER_ROUTES; }); }); }); linagora-ldap-rest-16e557e/test/plugins/auth/crowdsec.test.ts000066400000000000000000000137041522642357000243150ustar00rootroot00000000000000import { expect } from 'chai'; import request from 'supertest'; import nock from 'nock'; import type { Express } from 'express'; import { DM } from '../../../src/bin'; import CrowdSec from '../../../src/plugins/auth/crowdsec'; import DmPlugin from '../../../src/abstract/plugin'; // Simple plugin to test CrowdSec blocking class TestPlugin extends DmPlugin { name = 'testPlugin'; api(app: Express): void { app.get('/api/test', (req, res) => { res.json({ message: 'Success' }); }); } } describe('CrowdSec Plugin', () => { let dm: DM; let app: Express; const crowdsecUrl = 'http://localhost:8080'; const apiKey = 'test-api-key-123'; beforeEach(async () => { process.env.DM_CROWDSEC_URL = `${crowdsecUrl}/v1/decisions`; process.env.DM_CROWDSEC_API_KEY = apiKey; process.env.DM_CROWDSEC_CACHE_TTL = '1'; // 1 second cache for tests dm = new DM(); await dm.ready; const crowdsec = new CrowdSec(dm); const testPlugin = new TestPlugin(dm); await dm.registerPlugin('crowdsec', crowdsec); await dm.registerPlugin('testPlugin', testPlugin); app = dm.app; }); afterEach(() => { nock.cleanAll(); }); it('should allow requests from non-banned IPs', async () => { const clientIp = '192.168.1.100'; // Mock CrowdSec API returning no decision (null as string) nock(crowdsecUrl) .get('/v1/decisions') .query({ ip: clientIp }) .matchHeader('X-Api-Key', apiKey) .reply(200, 'null'); const response = await request(app) .get('/api/test') .set('X-Forwarded-For', clientIp); expect(response.status).to.equal(200); expect(response.body.message).to.equal('Success'); }); it('should block requests from banned IPs', async () => { const clientIp = '10.0.0.50'; // Mock CrowdSec API returning a ban decision nock(crowdsecUrl) .get('/v1/decisions') .query({ ip: clientIp }) .matchHeader('X-Api-Key', apiKey) .reply(200, [ { duration: '4h', id: 12345, origin: 'cscli', scenario: 'manual ban', scope: 'ip', type: 'ban', value: clientIp, }, ]); const response = await request(app) .get('/api/test') .set('X-Forwarded-For', clientIp); expect(response.status).to.equal(403); expect(response.body.error).to.equal('Access denied'); expect(response.body.reason).to.include('banned'); }); it('should allow requests if IP has non-ban decision', async () => { const clientIp = '172.16.0.10'; // Mock CrowdSec API returning a captcha decision (not ban) nock(crowdsecUrl) .get('/v1/decisions') .query({ ip: clientIp }) .matchHeader('X-Api-Key', apiKey) .reply(200, [ { duration: '1h', id: 54321, origin: 'cscli', scenario: 'test scenario', scope: 'ip', type: 'captcha', value: clientIp, }, ]); const response = await request(app) .get('/api/test') .set('X-Forwarded-For', clientIp); expect(response.status).to.equal(200); expect(response.body.message).to.equal('Success'); }); it('should use cache for repeated requests', async () => { const clientIp = '192.168.1.200'; // Mock CrowdSec API - should only be called once due to caching const scope = nock(crowdsecUrl) .get('/v1/decisions') .query({ ip: clientIp }) .matchHeader('X-Api-Key', apiKey) .reply(200, 'null'); // First request - will query CrowdSec await request(app).get('/api/test').set('X-Forwarded-For', clientIp); // Second request - should use cache const response = await request(app) .get('/api/test') .set('X-Forwarded-For', clientIp); expect(response.status).to.equal(200); expect(scope.isDone()).to.be.true; // Only one API call should have been made expect(nock.pendingMocks().length).to.equal(0); }); it('should fail open if CrowdSec API is unavailable', async () => { const clientIp = '192.168.1.250'; // Mock CrowdSec API returning error nock(crowdsecUrl) .get('/v1/decisions') .query({ ip: clientIp }) .matchHeader('X-Api-Key', apiKey) .reply(500, 'Internal Server Error'); const response = await request(app) .get('/api/test') .set('X-Forwarded-For', clientIp); // Should allow request despite CrowdSec error expect(response.status).to.equal(200); expect(response.body.message).to.equal('Success'); }); it('should handle multiple ban decisions correctly', async () => { const clientIp = '10.0.0.100'; // Mock CrowdSec API returning multiple decisions including ban nock(crowdsecUrl) .get('/v1/decisions') .query({ ip: clientIp }) .matchHeader('X-Api-Key', apiKey) .reply(200, [ { duration: '1h', id: 1, origin: 'cscli', scenario: 'captcha scenario', scope: 'ip', type: 'captcha', value: clientIp, }, { duration: '4h', id: 2, origin: 'cscli', scenario: 'ban scenario', scope: 'ip', type: 'ban', value: clientIp, }, ]); const response = await request(app) .get('/api/test') .set('X-Forwarded-For', clientIp); expect(response.status).to.equal(403); expect(response.body.error).to.equal('Access denied'); }); it('should extract IP from X-Forwarded-For with multiple proxies', async () => { const clientIp = '203.0.113.50'; const proxyChain = `${clientIp}, 10.0.0.1, 172.16.0.1`; // Mock CrowdSec API - should query for the first IP only nock(crowdsecUrl) .get('/v1/decisions') .query({ ip: clientIp }) .matchHeader('X-Api-Key', apiKey) .reply(200, 'null'); const response = await request(app) .get('/api/test') .set('X-Forwarded-For', proxyChain); expect(response.status).to.equal(200); }); }); linagora-ldap-rest-16e557e/test/plugins/auth/hmac.test.ts000066400000000000000000000441361522642357000234170ustar00rootroot00000000000000import { DM } from '../../../src/bin'; import type { Express } from 'express'; import request from 'supertest'; import AuthHmac from '../../../src/plugins/auth/hmac'; import HelloWorld from '../../../src/plugins/demo/helloworld'; import { expect } from 'chai'; import { createHmac, createHash } from 'crypto'; /** * Helper function to generate HMAC signature for testing */ function generateHmacSignature( secret: string, method: string, path: string, timestamp: number, body?: any ): string { // Calculate body hash let bodyHash = ''; if (body && (method === 'POST' || method === 'PATCH' || method === 'PUT')) { const bodyString = typeof body === 'string' ? body : JSON.stringify(body); const hash = createHash('sha256'); hash.update(bodyString); bodyHash = hash.digest('hex'); } // Create signing string: METHOD|PATH|timestamp|body-hash const signingString = `${method}|${path}|${timestamp}|${bodyHash}`; // Calculate HMAC-SHA256 const hmac = createHmac('sha256', secret); hmac.update(signingString); return hmac.digest('hex'); } /** * Helper function to create Authorization header */ function createAuthHeader( serviceId: string, timestamp: number, signature: string ): string { return `HMAC-SHA256 ${serviceId}:${timestamp}:${signature}`; } describe('AuthHmac', () => { describe('Basic HMAC authentication', () => { let dm: DM; let app: Express; const serviceId = 'registration-service'; const secret = 'test-secret-key-with-sufficient-length-for-security'; before(async () => { process.env.DM_AUTH_HMAC = `${serviceId}:${secret}:Registration Service`; process.env.DM_AUTH_HMAC_WINDOW = '120000'; // 2 minutes in ms dm = new DM(); await dm.ready; const p = new AuthHmac(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authHmac', p); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should return 401 if no Authorization header is provided', async () => { const res = await request(app).get('/api/hello'); expect(res.status).to.equal(401); expect(res.body).to.deep.equal({ error: 'Unauthorized' }); }); it('should return 401 if Authorization header has wrong format', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer some-token'); expect(res.status).to.equal(401); }); it('should return 401 if Authorization value is malformed', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'HMAC-SHA256 malformed'); expect(res.status).to.equal(401); }); it('should accept valid HMAC signature for GET request', async () => { const timestamp = Date.now(); const signature = generateHmacSignature( secret, 'GET', '/api/hello', timestamp ); const authHeader = createAuthHeader(serviceId, timestamp, signature); const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ message: 'Hello', hookResults: [] }); }); it('should accept valid HMAC signature with query parameters', async () => { const timestamp = Date.now(); const path = '/api/hello?param1=value1¶m2=value2'; const signature = generateHmacSignature(secret, 'GET', path, timestamp); const authHeader = createAuthHeader(serviceId, timestamp, signature); const res = await request(app).get(path).set('Authorization', authHeader); expect(res.status).to.equal(200); }); it('should reject request with invalid signature', async () => { const timestamp = Date.now(); const authHeader = createAuthHeader( serviceId, timestamp, 'invalid-signature' ); const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); expect(res.status).to.equal(401); }); it('should reject request with unknown service ID', async () => { const timestamp = Date.now(); const signature = generateHmacSignature( secret, 'GET', '/api/hello', timestamp ); const authHeader = createAuthHeader( 'unknown-service', timestamp, signature ); const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); expect(res.status).to.equal(401); }); it('should reject request with expired timestamp', async () => { const expiredTimestamp = Date.now() - 200000; // 200 seconds ago (> 2 min window) const signature = generateHmacSignature( secret, 'GET', '/api/hello', expiredTimestamp ); const authHeader = createAuthHeader( serviceId, expiredTimestamp, signature ); const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); expect(res.status).to.equal(401); }); it('should reject request with future timestamp outside window', async () => { const futureTimestamp = Date.now() + 200000; // 200 seconds in future const signature = generateHmacSignature( secret, 'GET', '/api/hello', futureTimestamp ); const authHeader = createAuthHeader( serviceId, futureTimestamp, signature ); const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); expect(res.status).to.equal(401); }); it('should reject request with invalid timestamp format', async () => { const signature = generateHmacSignature( secret, 'GET', '/api/hello', Date.now() ); const authHeader = `HMAC-SHA256 ${serviceId}:not-a-number:${signature}`; const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); expect(res.status).to.equal(401); }); }); describe('POST requests with body', () => { let dm: DM; let app: Express; const serviceId = 'test-service'; const secret = 'post-test-secret-key-with-sufficient-length'; before(async () => { process.env.DM_AUTH_HMAC = `${serviceId}:${secret}:Test Service`; dm = new DM(); await dm.ready; const p = new AuthHmac(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authHmac', p); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should validate signature with JSON body', async () => { const timestamp = Date.now(); const signature = generateHmacSignature( secret, 'GET', '/api/hello', timestamp, undefined ); const authHeader = createAuthHeader(serviceId, timestamp, signature); // Test that POST with valid HMAC auth passes auth (even if endpoint doesn't support POST) const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); // Should pass auth (not 401), even if method not supported (404 or 200 is fine) expect(res.status).to.not.equal(401); }); it('should reject POST with wrong body hash', async () => { const timestamp = Date.now(); const originalBody = { name: 'test' }; const differentBody = { name: 'different' }; // Sign with original body const signature = generateHmacSignature( secret, 'POST', '/api/hello', timestamp, originalBody ); const authHeader = createAuthHeader(serviceId, timestamp, signature); // Send different body const res = await request(app) .post('/api/hello') .set('Authorization', authHeader) .send(differentBody); expect(res.status).to.equal(401); }); }); describe('Multiple services', () => { let dm: DM; let app: Express; const service1Id = 'registration-service'; const service1Secret = 'registration-secret-key-long-enough'; const service2Id = 'cloudery'; const service2Secret = 'cloudery-secret-key-also-long-enough'; before(async () => { process.env.DM_AUTH_HMAC = [ `${service1Id}:${service1Secret}:Registration Service`, `${service2Id}:${service2Secret}:Cloudery Backend`, ].join(','); dm = new DM(); await dm.ready; const p = new AuthHmac(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authHmac', p); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should accept request from first service', async () => { const timestamp = Date.now(); const signature = generateHmacSignature( service1Secret, 'GET', '/api/hello', timestamp ); const authHeader = createAuthHeader(service1Id, timestamp, signature); const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); expect(res.status).to.equal(200); }); it('should accept request from second service', async () => { const timestamp = Date.now(); const signature = generateHmacSignature( service2Secret, 'GET', '/api/hello', timestamp ); const authHeader = createAuthHeader(service2Id, timestamp, signature); const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); expect(res.status).to.equal(200); }); it('should reject request with wrong secret for service', async () => { const timestamp = Date.now(); // Use service2's secret but service1's ID const signature = generateHmacSignature( service2Secret, 'GET', '/api/hello', timestamp ); const authHeader = createAuthHeader(service1Id, timestamp, signature); const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); expect(res.status).to.equal(401); }); }); describe('Different HTTP methods', () => { let dm: DM; let app: Express; const serviceId = 'test-service'; const secret = 'method-test-secret-key-with-length'; before(async () => { process.env.DM_AUTH_HMAC = `${serviceId}:${secret}:Test Service`; dm = new DM(); await dm.ready; const p = new AuthHmac(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authHmac', p); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should validate DELETE request (no body)', async () => { const timestamp = Date.now(); const signature = generateHmacSignature( secret, 'GET', '/api/hello', timestamp ); const authHeader = createAuthHeader(serviceId, timestamp, signature); const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); expect(res.status).to.not.equal(401); }); it('should validate PUT request with body', async () => { const timestamp = Date.now(); const signature = generateHmacSignature( secret, 'GET', '/api/hello', timestamp ); const authHeader = createAuthHeader(serviceId, timestamp, signature); const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); expect(res.status).to.not.equal(401); }); it('should validate PATCH request with body', async () => { const timestamp = Date.now(); const signature = generateHmacSignature( secret, 'GET', '/api/hello', timestamp ); const authHeader = createAuthHeader(serviceId, timestamp, signature); const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); expect(res.status).to.not.equal(401); }); }); describe('Custom time window', () => { let dm: DM; let app: Express; const serviceId = 'test-service'; const secret = 'time-window-test-secret-key-long'; before(async () => { process.env.DM_AUTH_HMAC = `${serviceId}:${secret}:Test Service`; process.env.DM_AUTH_HMAC_WINDOW = '60000'; // 1 minute window dm = new DM(); await dm.ready; const p = new AuthHmac(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authHmac', p); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should accept request within 1 minute window', async () => { const timestamp = Date.now() - 50000; // 50 seconds ago const signature = generateHmacSignature( secret, 'GET', '/api/hello', timestamp ); const authHeader = createAuthHeader(serviceId, timestamp, signature); const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); expect(res.status).to.equal(200); }); it('should reject request outside 1 minute window', async () => { const timestamp = Date.now() - 70000; // 70 seconds ago const signature = generateHmacSignature( secret, 'GET', '/api/hello', timestamp ); const authHeader = createAuthHeader(serviceId, timestamp, signature); const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); expect(res.status).to.equal(401); }); }); describe('Configuration validation', () => { it('should warn about short secrets', async () => { process.env.DM_AUTH_HMAC = 'service:short:Service Name'; const dm = new DM(); await dm.ready; const p = new AuthHmac(dm); // Plugin should initialize but log warning expect(p).to.not.be.null; }); it('should handle invalid config format', async () => { process.env.DM_AUTH_HMAC = 'invalid:format'; const dm = new DM(); await dm.ready; const p = new AuthHmac(dm); // Plugin should initialize but log warning expect(p).to.not.be.null; }); it('should handle config with colons in service name', async () => { process.env.DM_AUTH_HMAC = 'service:secret-key-long-enough:Service:With:Colons:In:Name'; const dm = new DM(); await dm.ready; const p = new AuthHmac(dm); // Plugin should parse correctly and join name parts expect(p).to.not.be.null; }); }); describe('Edge cases', () => { let dm: DM; let app: Express; const serviceId = 'test-service'; const secret = 'edge-case-test-secret-key-with-length'; before(async () => { process.env.DM_AUTH_HMAC = `${serviceId}:${secret}:Test Service`; dm = new DM(); await dm.ready; const p = new AuthHmac(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authHmac', p); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should handle empty path correctly', async () => { const timestamp = Date.now(); const signature = generateHmacSignature(secret, 'GET', '/', timestamp); const authHeader = createAuthHeader(serviceId, timestamp, signature); const res = await request(app).get('/').set('Authorization', authHeader); // May be 404 or other status depending on routing, but should not be 401 for auth expect(res.status).to.not.equal(401); }); it('should handle special characters in path', async () => { const timestamp = Date.now(); const path = '/api/hello?name=test%20user&id=123'; const signature = generateHmacSignature(secret, 'GET', path, timestamp); const authHeader = createAuthHeader(serviceId, timestamp, signature); const res = await request(app).get(path).set('Authorization', authHeader); expect(res.status).to.equal(200); }); it('should reject signature with slight timing difference', async () => { const timestamp1 = Date.now(); const signature = generateHmacSignature( secret, 'GET', '/api/hello', timestamp1 ); const timestamp2 = timestamp1 + 1; // Different timestamp const authHeader = createAuthHeader(serviceId, timestamp2, signature); const res = await request(app) .get('/api/hello') .set('Authorization', authHeader); expect(res.status).to.equal(401); }); }); /** * Cross-implementation contract test. * * The lsc-plugin/ Java client computes HMAC signatures the same way the * server here does. Both sides hard-code this same vector — if either side * changes the signing string format, the body hashing rule, or the * timestamp encoding, this test fails AND the matching Java test * (LdapRestAuthTest#hmacReproducibleSignature / hmacCrossImplVector) fails * on the other side. Keep them in sync. * * Vector: * secret = "test-secret-min-32-chars-long-xxx" * method = "POST" * path = "/api/v1/ldap/users" * timestamp = 1700000000000 * body = '{"uid":"alice"}' * expected = "65b065ff10ab2a54de0ab4db485c5744fcdd32a98e2fd24a8cef5240b43bbc94" */ describe('Cross-impl vector (lsc-plugin compatibility)', () => { const secret = 'test-secret-min-32-chars-long-xxx'; const method = 'POST'; const path = '/api/v1/ldap/users'; const timestamp = 1700000000000; const body = '{"uid":"alice"}'; const expectedSignature = '65b065ff10ab2a54de0ab4db485c5744fcdd32a98e2fd24a8cef5240b43bbc94'; it('Node helper must produce the same signature as the Java plugin', () => { const sig = generateHmacSignature(secret, method, path, timestamp, body); expect(sig).to.equal(expectedSignature); }); it('intermediate values match the documented format', () => { const bodyHash = createHash('sha256').update(body).digest('hex'); expect(bodyHash).to.equal( 'c9bfac238b197ff8c303f8186d216e1422495890f852043cbac3810d1d867822' ); const signingString = `${method}|${path}|${timestamp}|${bodyHash}`; const sig = createHmac('sha256', secret) .update(signingString) .digest('hex'); expect(sig).to.equal(expectedSignature); }); }); }); linagora-ldap-rest-16e557e/test/plugins/auth/openidconnect.test.ts000066400000000000000000000466751522642357000253510ustar00rootroot00000000000000import { expect } from 'chai'; import request from 'supertest'; import nock from 'nock'; import type { Express, Request, Response } from 'express'; import { DM } from '../../../src/bin'; import OpenIDConnect from '../../../src/plugins/auth/openidconnect'; import DmPlugin from '../../../src/abstract/plugin'; // Simple test plugin to verify auth flow class TestProtectedResource extends DmPlugin { name = 'testProtectedResource'; api(app: Express): void { app.get('/api/protected', (req, res) => { // @ts-expect-error req.user is set by OpenID Connect if (req.user) { res.json({ message: 'Access granted', // @ts-expect-error req.user is set by OpenID Connect user: req.user, }); } else { res.status(401).json({ error: 'Unauthorized' }); } }); } } describe('OpenID Connect Plugin', () => { describe('Configuration Validation', () => { it('should throw error if oidc_server is missing', async () => { const dm = new DM(); await dm.ready; // Missing oidc_server dm.config.oidc_client_id = 'test-client-id'; dm.config.oidc_client_secret = 'test-client-secret'; dm.config.base_url = 'http://localhost:3000'; expect(() => new OpenIDConnect(dm)).to.throw( 'Missing config parameter oidc_server' ); }); it('should throw error if oidc_client_id is missing', async () => { const dm = new DM(); await dm.ready; dm.config.oidc_server = 'http://localhost:8080'; // Missing oidc_client_id dm.config.oidc_client_secret = 'test-client-secret'; dm.config.base_url = 'http://localhost:3000'; expect(() => new OpenIDConnect(dm)).to.throw( 'Missing config parameter oidc_client_id' ); }); it('should throw error if oidc_client_secret is missing', async () => { const dm = new DM(); await dm.ready; dm.config.oidc_server = 'http://localhost:8080'; dm.config.oidc_client_id = 'test-client-id'; // Missing oidc_client_secret dm.config.base_url = 'http://localhost:3000'; expect(() => new OpenIDConnect(dm)).to.throw( 'Missing config parameter oidc_client_secret' ); }); it('should throw error if base_url is missing', async () => { const dm = new DM(); await dm.ready; dm.config.oidc_server = 'http://localhost:8080'; dm.config.oidc_client_id = 'test-client-id'; dm.config.oidc_client_secret = 'test-client-secret'; // Missing base_url expect(() => new OpenIDConnect(dm)).to.throw( 'Missing config parameter base_url' ); }); it('should create plugin with all required config parameters', async () => { const dm = new DM(); await dm.ready; dm.config.oidc_server = 'http://localhost:8080'; dm.config.oidc_client_id = 'test-client-id'; dm.config.oidc_client_secret = 'test-client-secret'; dm.config.base_url = 'http://localhost:3000'; const plugin = new OpenIDConnect(dm); expect(plugin).to.be.an.instanceOf(OpenIDConnect); expect(plugin.name).to.equal('openidconnect'); expect(plugin.roles).to.deep.equal(['auth']); }); }); describe('Plugin Properties', () => { let dm: DM; let plugin: OpenIDConnect; before(async () => { dm = new DM(); await dm.ready; dm.config.oidc_server = 'http://localhost:8080'; dm.config.oidc_client_id = 'test-client-id'; dm.config.oidc_client_secret = 'test-client-secret'; dm.config.base_url = 'http://localhost:3000'; plugin = new OpenIDConnect(dm); }); it('should have correct plugin name', () => { expect(plugin.name).to.equal('openidconnect'); }); it('should have auth role', () => { expect(plugin.roles).to.include('auth'); expect(plugin.roles.length).to.equal(1); }); it('should have api method', () => { expect(plugin.api).to.be.a('function'); }); it('should have authMethod', () => { expect(plugin.authMethod).to.be.a('function'); }); }); describe('Hook Integration', () => { it('should register with DM hooks system', async () => { const dm = new DM(); await dm.ready; dm.config.oidc_server = 'http://localhost:8080'; dm.config.oidc_client_id = 'test-client-id'; dm.config.oidc_client_secret = 'test-client-secret'; dm.config.base_url = 'http://localhost:3000'; const plugin = new OpenIDConnect(dm); const result = await dm.registerPlugin('openidconnect', plugin); // Verify plugin registration completed without errors expect(result).to.not.throw; expect(plugin.name).to.equal('openidconnect'); }); it('should support beforeAuth and afterAuth hooks', async () => { const dm = new DM(); await dm.ready; dm.config.oidc_server = 'http://localhost:8080'; dm.config.oidc_client_id = 'test-client-id'; dm.config.oidc_client_secret = 'test-client-secret'; dm.config.base_url = 'http://localhost:3000'; const plugin = new OpenIDConnect(dm); await dm.registerPlugin('openidconnect', plugin); // The plugin uses beforeAuth and afterAuth hooks // Verify plugin was registered and has correct role expect(plugin.roles).to.include('auth'); expect(plugin.name).to.equal('openidconnect'); }); }); describe('Configuration Structure', () => { it('should pass correct config to express-openid-connect', async () => { const dm = new DM(); await dm.ready; dm.config.oidc_server = 'https://auth.example.com'; dm.config.oidc_client_id = 'my-client-id'; dm.config.oidc_client_secret = 'my-client-secret'; dm.config.base_url = 'https://app.example.com'; const plugin = new OpenIDConnect(dm); // Verify plugin stores reference to server expect(plugin.server).to.equal(dm); expect(plugin.config.oidc_server).to.equal('https://auth.example.com'); expect(plugin.config.oidc_client_id).to.equal('my-client-id'); expect(plugin.config.oidc_client_secret).to.equal('my-client-secret'); expect(plugin.config.base_url).to.equal('https://app.example.com'); }); }); describe('Multiple Instances', () => { it('should allow creating multiple instances with different configs', async () => { const dm1 = new DM(); await dm1.ready; dm1.config.oidc_server = 'http://auth1.example.com'; dm1.config.oidc_client_id = 'client1'; dm1.config.oidc_client_secret = 'secret1'; dm1.config.base_url = 'http://app1.example.com'; const plugin1 = new OpenIDConnect(dm1); const dm2 = new DM(); await dm2.ready; dm2.config.oidc_server = 'http://auth2.example.com'; dm2.config.oidc_client_id = 'client2'; dm2.config.oidc_client_secret = 'secret2'; dm2.config.base_url = 'http://app2.example.com'; const plugin2 = new OpenIDConnect(dm2); expect(plugin1.config.oidc_server).to.equal('http://auth1.example.com'); expect(plugin2.config.oidc_server).to.equal('http://auth2.example.com'); }); }); describe('Error Handling', () => { it('should handle undefined config values', async () => { const dm = new DM(); await dm.ready; // All config values are undefined expect(() => new OpenIDConnect(dm)).to.throw('Missing config parameter'); }); it('should handle null config values', async () => { const dm = new DM(); await dm.ready; dm.config.oidc_server = undefined; dm.config.oidc_client_id = undefined; dm.config.oidc_client_secret = undefined; dm.config.base_url = undefined; expect(() => new OpenIDConnect(dm)).to.throw('Missing config parameter'); }); it('should handle empty string config values', async () => { const dm = new DM(); await dm.ready; dm.config.oidc_server = ''; dm.config.oidc_client_id = 'client-id'; dm.config.oidc_client_secret = 'secret'; dm.config.base_url = 'http://localhost:3000'; expect(() => new OpenIDConnect(dm)).to.throw( 'Missing config parameter oidc_server' ); }); }); describe('Plugin Registration Order', () => { it('should work when registered before other plugins', async () => { const dm = new DM(); await dm.ready; dm.config.oidc_server = 'http://localhost:8080'; dm.config.oidc_client_id = 'test-client-id'; dm.config.oidc_client_secret = 'test-client-secret'; dm.config.base_url = 'http://localhost:3000'; const oidcPlugin = new OpenIDConnect(dm); // Register OpenID Connect first const result = await dm.registerPlugin('openidconnect', oidcPlugin); expect(result).to.not.throw; expect(oidcPlugin.name).to.equal('openidconnect'); }); it('should work when registered after other plugins', async () => { const dm = new DM(); await dm.ready; dm.config.oidc_server = 'http://localhost:8080'; dm.config.oidc_client_id = 'test-client-id'; dm.config.oidc_client_secret = 'test-client-secret'; dm.config.base_url = 'http://localhost:3000'; const oidcPlugin = new OpenIDConnect(dm); // Register OpenID Connect after DM is ready const result = await dm.registerPlugin('openidconnect', oidcPlugin); expect(result).to.not.throw; expect(oidcPlugin.server).to.equal(dm); }); }); describe('OIDC Server Integration', () => { const oidcServer = 'http://auth.test.local'; let app: Express; let dm: DM; beforeEach(async () => { // Mock OIDC discovery endpoint nock(oidcServer) .persist() .get('/.well-known/openid-configuration') .reply(200, { issuer: oidcServer, authorization_endpoint: `${oidcServer}/authorize`, token_endpoint: `${oidcServer}/token`, userinfo_endpoint: `${oidcServer}/userinfo`, jwks_uri: `${oidcServer}/jwks`, response_types_supported: ['code'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], }); // Mock JWKS endpoint nock(oidcServer) .persist() .get('/jwks') .reply(200, { keys: [ { kty: 'RSA', kid: 'test-key-id', use: 'sig', n: 'xGOr-H7A-PWp_4NWiCAHF0K_mH24-lJNHGsXpMB', e: 'AQAB', }, ], }); dm = new DM(); await dm.ready; dm.config.oidc_server = oidcServer; dm.config.oidc_client_id = 'test-client-id'; dm.config.oidc_client_secret = 'test-client-secret'; dm.config.base_url = 'http://localhost:3000'; const oidcPlugin = new OpenIDConnect(dm); const testPlugin = new TestProtectedResource(dm); await dm.registerPlugin('openidconnect', oidcPlugin); await dm.registerPlugin('testProtectedResource', testPlugin); app = dm.app; }); afterEach(() => { nock.cleanAll(); }); it('should initialize with OIDC discovery endpoint', async () => { // The plugin should query the discovery endpoint during initialization // This is handled by express-openid-connect const plugin = new OpenIDConnect(dm); expect(plugin).to.be.instanceOf(OpenIDConnect); }); it('should configure authorization parameters correctly', async () => { const plugin = new OpenIDConnect(dm); // Verify the plugin has correct configuration expect(plugin.config.oidc_server).to.equal(oidcServer); expect(plugin.config.oidc_client_id).to.equal('test-client-id'); }); it('should handle token exchange with OIDC server', async () => { // Mock token endpoint const tokenScope = nock(oidcServer).post('/token').reply(200, { access_token: 'test-access-token', token_type: 'Bearer', expires_in: 3600, id_token: 'test-id-token', }); // This test verifies that the configuration is correct // The actual token exchange is handled by express-openid-connect expect(tokenScope).to.not.be.undefined; }); it('should handle userinfo endpoint responses', async () => { // Mock userinfo endpoint const userinfoScope = nock(oidcServer) .get('/userinfo') .matchHeader('Authorization', /Bearer .+/) .reply(200, { sub: 'user-123', name: 'Test User', email: 'test@example.com', email_verified: true, }); // Verify the mock is set up expect(userinfoScope).to.not.be.undefined; }); it('should handle OIDC server errors gracefully', async () => { // Mock discovery endpoint failure nock.cleanAll(); nock(oidcServer) .get('/.well-known/openid-configuration') .reply(500, 'Internal Server Error'); // The plugin should handle this gracefully // express-openid-connect will retry or fail open depending on config const plugin = new OpenIDConnect(dm); expect(plugin).to.be.instanceOf(OpenIDConnect); }); it('should handle invalid token responses', async () => { // Mock token endpoint with invalid response nock(oidcServer).post('/token').reply(401, { error: 'invalid_client', error_description: 'Client authentication failed', }); // The plugin should handle this via express-openid-connect const plugin = new OpenIDConnect(dm); expect(plugin).to.be.instanceOf(OpenIDConnect); }); it('should handle JWKS refresh', async () => { // Mock JWKS endpoint with new keys const jwksScope = nock(oidcServer) .get('/jwks') .reply(200, { keys: [ { kty: 'RSA', kid: 'new-key-id', use: 'sig', n: 'yHPs-I8B-QXq_5OXjDBIG1L_nI35-mKOIHtYqNMC', e: 'AQAB', }, ], }); // Verify the mock is set up for JWKS refresh expect(jwksScope).to.not.be.undefined; }); it('should validate required scopes', () => { const plugin = new OpenIDConnect(dm); // The plugin requests 'openid profile email' scopes // Verify configuration is set up correctly expect(plugin.config.oidc_server).to.equal(oidcServer); }); it('should handle OAuth state parameter for CSRF protection', async () => { let capturedState = ''; // Mock authorization endpoint that captures the state nock(oidcServer) .get('/authorize') .query(true) // Match any query parameters .reply(uri => { const url = new URL(uri, oidcServer); capturedState = url.searchParams.get('state') || ''; // Return redirect with state and code return [ 302, '', { Location: `http://localhost:3000/callback?code=test-code&state=${capturedState}`, }, ]; }); // Mock token endpoint that validates the code nock(oidcServer) .post('/token', body => { // Verify the token request includes the authorization code return ( typeof body === 'object' && body !== null && 'code' in body && body.code === 'test-code' ); }) .reply(200, { access_token: 'test-access-token', token_type: 'Bearer', expires_in: 3600, id_token: 'test-id-token', }); // Mock userinfo endpoint nock(oidcServer) .get('/userinfo') .matchHeader('Authorization', 'Bearer test-access-token') .reply(200, { sub: 'user-456', name: 'Test User', email: 'test@example.com', }); // The state parameter should be generated and verified by express-openid-connect const plugin = new OpenIDConnect(dm); expect(plugin).to.be.instanceOf(OpenIDConnect); }); it('should reject callback with mismatched state parameter', async () => { // Mock callback endpoint with wrong state nock(oidcServer).post('/token').reply(400, { error: 'invalid_request', error_description: 'State parameter mismatch', }); // The plugin/express-openid-connect should reject this const plugin = new OpenIDConnect(dm); expect(plugin).to.be.instanceOf(OpenIDConnect); }); it('should handle authorization code flow with state', async () => { const testState = 'random-csrf-state-value'; const authCode = 'auth-code-12345'; // Mock the complete authorization code flow nock(oidcServer) .get('/authorize') .query(query => { // Verify state is present in authorization request return query.state !== undefined; }) .reply(302, '', { Location: `http://localhost:3000/callback?code=${authCode}&state=${testState}`, }); nock(oidcServer) .post('/token', body => { return ( typeof body === 'object' && body !== null && 'code' in body && body.code === authCode ); }) .reply(200, { access_token: 'access-token-xyz', token_type: 'Bearer', expires_in: 3600, id_token: 'eyJ.test.token', refresh_token: 'refresh-token-abc', }); const plugin = new OpenIDConnect(dm); expect(plugin).to.be.instanceOf(OpenIDConnect); }); }); describe('Hook Execution', () => { let dm: DM; let beforeAuthCalled = false; let afterAuthCalled = false; beforeEach(async () => { beforeAuthCalled = false; afterAuthCalled = false; dm = new DM(); await dm.ready; // Mock OIDC server const oidcServer = 'http://auth.test.local'; nock(oidcServer) .persist() .get('/.well-known/openid-configuration') .reply(200, { issuer: oidcServer, authorization_endpoint: `${oidcServer}/authorize`, token_endpoint: `${oidcServer}/token`, userinfo_endpoint: `${oidcServer}/userinfo`, jwks_uri: `${oidcServer}/jwks`, }); nock(oidcServer).persist().get('/jwks').reply(200, { keys: [] }); dm.config.oidc_server = oidcServer; dm.config.oidc_client_id = 'test-client'; dm.config.oidc_client_secret = 'test-secret'; dm.config.base_url = 'http://localhost:3000'; // Add hooks dm.hooks.beforeAuth = [ async (req: Request, res: Response) => { beforeAuthCalled = true; return [req, res]; }, ]; dm.hooks.afterAuth = [ async (req: Request, res: Response) => { afterAuthCalled = true; return [req, res]; }, ]; }); afterEach(() => { nock.cleanAll(); }); it('should call beforeAuth and afterAuth hooks during authentication', async () => { const oidcPlugin = new OpenIDConnect(dm); await dm.registerPlugin('openidconnect', oidcPlugin); // Verify hooks are available expect(dm.hooks.beforeAuth).to.be.an('array'); expect(dm.hooks.afterAuth).to.be.an('array'); }); it('should handle hook errors gracefully', async () => { dm.hooks.beforeAuth = [ async () => { throw new Error('Hook error'); }, ]; const oidcPlugin = new OpenIDConnect(dm); const result = await dm.registerPlugin('openidconnect', oidcPlugin); // Plugin should register despite hook errors expect(result).to.not.throw; expect(oidcPlugin.name).to.equal('openidconnect'); }); }); }); linagora-ldap-rest-16e557e/test/plugins/auth/rateLimit.test.ts000066400000000000000000000116201522642357000244310ustar00rootroot00000000000000import { expect } from 'chai'; import request from 'supertest'; import type { Express, Request, Response } from 'express'; import { DM } from '../../../src/bin'; import AuthBase, { type DmRequest } from '../../../src/lib/auth/base'; import RateLimit from '../../../src/plugins/auth/rateLimit'; // Simple auth plugin that accepts "valid-token" and rejects others class TestAuthPlugin extends AuthBase { name = 'testAuth'; // Need this to trigger afterAuth hook in AuthBase hooks = { onAuth: (): void => { // Triggers afterAuth hook chain }, }; authMethod(req: DmRequest, res: Response, next: () => void): void { const token = req.headers.authorization?.replace('Bearer ', ''); if (token === 'valid-token') { next(); } else { res.status(401).json({ error: 'Unauthorized' }); } } api(app: Express): void { // Call parent to set up beforeAuth/afterAuth hooks super.api(app); // Add our test route app.get('/api/test', (req: Request, res: Response) => { res.json({ message: 'Success' }); }); } } describe('Rate Limit Plugin', () => { let dm: DM; let app: Express; beforeEach(async () => { process.env.DM_RATE_LIMIT_WINDOW_MS = '60000'; // 1 minute process.env.DM_RATE_LIMIT_MAX = '5'; // 5 attempts max dm = new DM(); await dm.ready; const rateLimit = new RateLimit(dm); const testAuth = new TestAuthPlugin(dm); await dm.registerPlugin('rateLimit', rateLimit); await dm.registerPlugin('testAuth', testAuth); app = dm.app; }); it('should allow requests with valid token', async () => { const response = await request(app) .get('/api/test') .set('Authorization', 'Bearer valid-token'); expect(response.status).to.equal(200); expect(response.body.message).to.equal('Success'); }); it('should reject requests without valid token', async () => { const response = await request(app) .get('/api/test') .set('Authorization', 'Bearer invalid-token'); expect(response.status).to.equal(401); expect(response.body.error).to.equal('Unauthorized'); }); it('should not rate-limit successful requests', async () => { // Make 10 successful requests (more than the limit of 5) for (let i = 0; i < 10; i++) { const response = await request(app) .get('/api/test') .set('Authorization', 'Bearer valid-token'); expect(response.status).to.equal(200); expect(response.body.message).to.equal('Success'); } }); it('should rate-limit after multiple failed auth attempts', async () => { // Make 5 failed attempts (the max limit) for (let i = 0; i < 5; i++) { const response = await request(app) .get('/api/test') .set('Authorization', 'Bearer invalid-token'); expect(response.status).to.equal(401); } // The 6th failed attempt should be rate-limited const response = await request(app) .get('/api/test') .set('Authorization', 'Bearer invalid-token'); expect(response.status).to.equal(429); expect(response.body.error).to.include('Too many authentication attempts'); expect(response.body.retryAfter).to.be.a('number'); }); it('should rate-limit based on IP address', async () => { // Simulate requests from different IPs using X-Forwarded-For // IP 1: Make 5 failed attempts for (let i = 0; i < 5; i++) { await request(app) .get('/api/test') .set('Authorization', 'Bearer invalid-token') .set('X-Forwarded-For', '192.168.1.1'); } // IP 1: Should be rate-limited const response1 = await request(app) .get('/api/test') .set('Authorization', 'Bearer invalid-token') .set('X-Forwarded-For', '192.168.1.1'); expect(response1.status).to.equal(429); // IP 2: Should NOT be rate-limited (different IP) const response2 = await request(app) .get('/api/test') .set('Authorization', 'Bearer invalid-token') .set('X-Forwarded-For', '192.168.1.2'); expect(response2.status).to.equal(401); }); it('should block all requests from rate-limited IP', async () => { // Make 5 failed attempts to trigger rate limit for (let i = 0; i < 5; i++) { await request(app) .get('/api/test') .set('Authorization', 'Bearer invalid-token'); } // Verify rate-limited for invalid token const rateLimitedResponse = await request(app) .get('/api/test') .set('Authorization', 'Bearer invalid-token'); expect(rateLimitedResponse.status).to.equal(429); // Even with a valid token, rate-limited IP should be blocked // (this prevents attackers from continuing after a successful brute force) const validResponse = await request(app) .get('/api/test') .set('Authorization', 'Bearer valid-token'); expect(validResponse.status).to.equal(429); expect(validResponse.body.error).to.include( 'Too many authentication attempts' ); }); }); linagora-ldap-rest-16e557e/test/plugins/auth/token.test.ts000066400000000000000000000161241522642357000236230ustar00rootroot00000000000000import { DM } from '../../../src/bin'; import type { Express } from 'express'; import request from 'supertest'; import AuthToken from '../../../src/plugins/auth/token'; import HelloWorld from '../../../src/plugins/demo/helloworld'; import { expect } from 'chai'; describe('AuthToken', () => { describe('Unnamed tokens (legacy)', () => { let dm: DM; let app: Express; before(async () => { process.env.DM_AUTH_TOKENS = 'secrettoken1,secrettoken2'; const dm = new DM(); await dm.ready; const p = new AuthToken(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authToken', p); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should return 401 if no token is provided', async () => { // Test implementation const res = await request(app).get('/api/hello'); expect(res.status).to.equal(401); expect(res.body).to.deep.equal({ error: 'Unauthorized' }); }); it('should return 401 if an invalid token is provided', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer invalidtoken'); expect(res.status).to.equal(401); expect(res.body).to.deep.equal({ error: 'Unauthorized' }); }); it('should accept valid tokens', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer secrettoken1'); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ message: 'Hello', hookResults: [] }); }); it('should accept valid second tokens', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer secrettoken2'); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ message: 'Hello', hookResults: [] }); }); }); describe('Named tokens', () => { let dm: DM; let app: Express; let authPlugin: AuthToken; before(async () => { process.env.DM_AUTH_TOKENS = 'abc123:web-app,def456:monitoring,ghi789:backup'; dm = new DM(); await dm.ready; authPlugin = new AuthToken(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authToken', authPlugin); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should accept token with name', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer abc123'); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ message: 'Hello', hookResults: [] }); }); it('should accept second named token', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer def456'); expect(res.status).to.equal(200); }); it('should accept third named token', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer ghi789'); expect(res.status).to.equal(200); }); it('should reject invalid token', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer wrongtoken'); expect(res.status).to.equal(401); }); it('should reject token name as authentication', async () => { // User should not be able to authenticate using the name const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer web-app'); expect(res.status).to.equal(401); }); it('should set req.user to token name', async () => { // This tests that the token name is properly set in req.user // We can verify this by checking logs or hooks, but for now // we just verify that authentication succeeds const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer abc123'); expect(res.status).to.equal(200); // In a real scenario, req.user would be "web-app" }); }); describe('Mixed named and unnamed tokens', () => { let dm: DM; let app: Express; before(async () => { process.env.DM_AUTH_TOKENS = 'abc123:named-token,plaintoken'; dm = new DM(); await dm.ready; const p = new AuthToken(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authToken', p); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should accept named token', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer abc123'); expect(res.status).to.equal(200); }); it('should accept unnamed token', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer plaintoken'); expect(res.status).to.equal(200); // req.user would be "token 1" for this one }); it('should reject invalid token', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer invalid'); expect(res.status).to.equal(401); }); }); describe('Token with colon in name', () => { let dm: DM; let app: Express; before(async () => { // Test edge case: what if the name contains a colon? process.env.DM_AUTH_TOKENS = 'token123:service:production'; dm = new DM(); await dm.ready; const p = new AuthToken(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authToken', p); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should accept token and use everything after first colon as name', async () => { // With split(':', 2), "token123:service:production" becomes // token="token123", name="service:production" const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer token123'); expect(res.status).to.equal(200); // req.user would be "service:production" }); }); describe('Tokens with whitespace', () => { let dm: DM; let app: Express; before(async () => { // Test that trim() works properly process.env.DM_AUTH_TOKENS = ' abc123 : web-app , def456 : monitoring '; dm = new DM(); await dm.ready; const p = new AuthToken(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authToken', p); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should accept token with trimmed whitespace', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer abc123'); expect(res.status).to.equal(200); }); it('should accept second token with trimmed whitespace', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer def456'); expect(res.status).to.equal(200); }); it('should reject token with whitespace', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer abc123 '); expect(res.status).to.equal(401); }); }); }); linagora-ldap-rest-16e557e/test/plugins/auth/totp.test.ts000066400000000000000000000225601522642357000234720ustar00rootroot00000000000000import { DM } from '../../../src/bin'; import type { Express } from 'express'; import request from 'supertest'; import AuthTotp from '../../../src/plugins/auth/totp'; import HelloWorld from '../../../src/plugins/demo/helloworld'; import { expect } from 'chai'; import { createHmac } from 'crypto'; /** * Generate a TOTP code for testing */ function generateTestTotp( secret: string, digits: number, step: number ): string { const now = Math.floor(Date.now() / 1000); const counter = Math.floor(now / step); // Decode Base32 secret const key = base32Decode(secret); // Convert counter to 8-byte buffer (big-endian) const counterBuffer = Buffer.alloc(8); counterBuffer.writeBigUInt64BE(BigInt(counter)); // Generate HMAC-SHA1 const hmac = createHmac('sha1', key); hmac.update(counterBuffer); const hash = hmac.digest(); // Dynamic truncation (RFC 4226) const offset = hash[hash.length - 1] & 0x0f; const truncatedHash = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16) | ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff); // Generate N-digit code const code = truncatedHash % Math.pow(10, digits); return code.toString().padStart(digits, '0'); } /** * Decode Base32 for testing */ function base32Decode(input: string): Buffer { const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const cleanInput = input.toUpperCase().replace(/=+$/, ''); let bits = 0; let value = 0; const output: number[] = []; for (let i = 0; i < cleanInput.length; i++) { const idx = base32Chars.indexOf(cleanInput[i]); if (idx === -1) { throw new Error(`Invalid Base32 character: ${cleanInput[i]}`); } value = (value << 5) | idx; bits += 5; if (bits >= 8) { output.push((value >>> (bits - 8)) & 0xff); bits -= 8; } } return Buffer.from(output); } describe('AuthTotp', () => { describe('Basic TOTP authentication', () => { let dm: DM; let app: Express; const testSecret = 'JBSWY3DPEHPK3PXP'; // Standard test secret before(async () => { process.env.DM_AUTH_TOTP = `${testSecret}:admin:6`; dm = new DM(); await dm.ready; const p = new AuthTotp(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authTotp', p); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should return 401 if no token is provided', async () => { const res = await request(app).get('/api/hello'); expect(res.status).to.equal(401); expect(res.body).to.deep.equal({ error: 'Unauthorized' }); }); it('should return 401 if an invalid token is provided', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', 'Bearer 000000'); expect(res.status).to.equal(401); expect(res.body).to.deep.equal({ error: 'Unauthorized' }); }); it('should accept valid TOTP code', async () => { const code = generateTestTotp(testSecret, 6, 30); const res = await request(app) .get('/api/hello') .set('Authorization', `Bearer ${code}`); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ message: 'Hello', hookResults: [] }); }); it('should reject invalid Authorization format', async () => { const res = await request(app) .get('/api/hello') .set('Authorization', '123456'); expect(res.status).to.equal(401); }); }); describe('Multiple users with different digit counts', () => { let dm: DM; let app: Express; const secret6 = 'JBSWY3DPEHPK3PXP'; // 6 digits const secret8 = 'HXDMVJECJJWSRB3H'; // 8 digits const secret10 = 'IXDMVJECJJWSRB2A'; // 10 digits (custom) before(async () => { process.env.DM_AUTH_TOTP = `${secret6}:user6:6,${secret8}:user8:8,${secret10}:user10:10`; process.env.DM_AUTH_TOTP_STEP = '30'; process.env.DM_AUTH_TOTP_WINDOW = '1'; dm = new DM(); await dm.ready; const p = new AuthTotp(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authTotp', p); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should accept valid 6-digit TOTP code', async () => { const code = generateTestTotp(secret6, 6, 30); expect(code.length).to.equal(6); const res = await request(app) .get('/api/hello') .set('Authorization', `Bearer ${code}`); expect(res.status).to.equal(200); }); it('should accept valid 8-digit TOTP code', async () => { const code = generateTestTotp(secret8, 8, 30); expect(code.length).to.equal(8); const res = await request(app) .get('/api/hello') .set('Authorization', `Bearer ${code}`); expect(res.status).to.equal(200); }); it('should accept valid 10-digit TOTP code', async () => { const code = generateTestTotp(secret10, 10, 30); expect(code.length).to.equal(10); const res = await request(app) .get('/api/hello') .set('Authorization', `Bearer ${code}`); expect(res.status).to.equal(200); }); it('should reject 8-digit code for 6-digit user', async () => { const code = generateTestTotp(secret6, 8, 30); const res = await request(app) .get('/api/hello') .set('Authorization', `Bearer ${code}`); expect(res.status).to.equal(401); }); }); describe('Default digits (6)', () => { let dm: DM; let app: Express; const testSecret = 'JBSWY3DPEHPK3PXP'; before(async () => { // Format: "secret:name" without digits (should default to 6) process.env.DM_AUTH_TOTP = `${testSecret}:admin`; dm = new DM(); await dm.ready; const p = new AuthTotp(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authTotp', p); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should use 6 digits by default', async () => { const code = generateTestTotp(testSecret, 6, 30); expect(code.length).to.equal(6); const res = await request(app) .get('/api/hello') .set('Authorization', `Bearer ${code}`); expect(res.status).to.equal(200); }); }); describe('Custom time step', () => { let dm: DM; let app: Express; const testSecret = 'JBSWY3DPEHPK3PXP'; before(async () => { process.env.DM_AUTH_TOTP = `${testSecret}:admin:6`; process.env.DM_AUTH_TOTP_STEP = '60'; // 60 seconds instead of 30 dm = new DM(); await dm.ready; const p = new AuthTotp(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authTotp', p); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should accept valid TOTP code with custom step', async () => { const code = generateTestTotp(testSecret, 6, 60); const res = await request(app) .get('/api/hello') .set('Authorization', `Bearer ${code}`); expect(res.status).to.equal(200); }); it('should reject code generated with wrong step', async () => { const wrongCode = generateTestTotp(testSecret, 6, 30); await request(app) .get('/api/hello') .set('Authorization', `Bearer ${wrongCode}`); // This might pass or fail depending on timing, but it's a good edge case // In most cases, it should fail }); }); describe('Invalid configuration', () => { it('should handle invalid Base32 secret gracefully', async () => { process.env.DM_AUTH_TOTP = 'INVALID!!!:admin:6'; const dm = new DM(); await dm.ready; const p = new AuthTotp(dm); // Plugin should initialize but log warning expect(p).to.not.be.null; }); it('should handle invalid digits count', async () => { process.env.DM_AUTH_TOTP = 'JBSWY3DPEHPK3PXP:admin:3'; const dm = new DM(); await dm.ready; const p = new AuthTotp(dm); // Plugin should initialize but log warning expect(p).to.not.be.null; }); it('should handle malformed config', async () => { process.env.DM_AUTH_TOTP = 'onlyonesegment'; const dm = new DM(); await dm.ready; const p = new AuthTotp(dm); // Plugin should initialize but log warning expect(p).to.not.be.null; }); }); describe('Multiple users separated by comma', () => { let dm: DM; let app: Express; const secret1 = 'JBSWY3DPEHPK3PXP'; const secret2 = 'HXDMVJECJJWSRB3H'; before(async () => { // Test multiple users with comma separator process.env.DM_AUTH_TOTP = `${secret1}:user1:6,${secret2}:user2:8`; process.env.DM_AUTH_TOTP_STEP = '30'; process.env.DM_AUTH_TOTP_WINDOW = '1'; dm = new DM(); await dm.ready; const p = new AuthTotp(dm); const h = new HelloWorld(dm); await dm.registerPlugin('authTotp', p); await dm.registerPlugin('helloWorld', h); app = dm.app; }); it('should accept code from first user', async () => { const code = generateTestTotp(secret1, 6, 30); const res = await request(app) .get('/api/hello') .set('Authorization', `Bearer ${code}`); expect(res.status).to.equal(200); }); it('should accept code from second user', async () => { const code = generateTestTotp(secret2, 8, 30); const res = await request(app) .get('/api/hello') .set('Authorization', `Bearer ${code}`); expect(res.status).to.equal(200); }); }); }); linagora-ldap-rest-16e557e/test/plugins/auth/trustedProxy.test.ts000066400000000000000000000312771522642357000252450ustar00rootroot00000000000000import { expect } from 'chai'; import request from 'supertest'; import type { Express, Request, Response } from 'express'; import { DM } from '../../../src/bin'; import DmPlugin from '../../../src/abstract/plugin'; import TrustedProxy from '../../../src/plugins/auth/trustedProxy'; // Simple plugin that exposes the X-Forwarded-For header value and trusted proxy info class HeaderInspector extends DmPlugin { name = 'headerInspector'; api(app: Express): void { app.get('/api/inspect', (req: Request, res: Response) => { res.json({ xForwardedFor: req.headers['x-forwarded-for'] || null, remoteAddress: req.socket.remoteAddress, trustedProxy: req.trustedProxy || false, proxyAuthUser: req.proxyAuthUser || null, }); }); } } describe('TrustedProxy Plugin', () => { describe('Configuration - no config', () => { before(() => { delete process.env.DM_TRUSTED_PROXIES; }); it('should throw error if trusted_proxy is not configured', async () => { const dm = new DM(); await dm.ready; expect(() => new TrustedProxy(dm)).to.throw( 'TrustedProxy plugin requires trusted_proxy configuration' ); }); }); describe('Configuration - invalid IP', () => { before(() => { process.env.DM_TRUSTED_PROXIES = 'not-an-ip'; }); after(() => { delete process.env.DM_TRUSTED_PROXIES; }); it('should throw error for invalid IP address', async () => { const dm = new DM(); await dm.ready; expect(() => new TrustedProxy(dm)).to.throw('Invalid'); }); }); describe('Configuration - invalid CIDR', () => { before(() => { process.env.DM_TRUSTED_PROXIES = '192.168.1.0/99'; }); after(() => { delete process.env.DM_TRUSTED_PROXIES; }); it('should throw error for invalid CIDR notation', async () => { const dm = new DM(); await dm.ready; expect(() => new TrustedProxy(dm)).to.throw('Invalid prefix length'); }); }); describe('Configuration - valid IPv4', () => { before(() => { process.env.DM_TRUSTED_PROXIES = '127.0.0.1,192.168.1.1'; }); after(() => { delete process.env.DM_TRUSTED_PROXIES; }); it('should accept valid IPv4 addresses', async () => { const dm = new DM(); await dm.ready; expect(() => new TrustedProxy(dm)).to.not.throw(); }); }); describe('Configuration - valid CIDR', () => { before(() => { process.env.DM_TRUSTED_PROXIES = '10.0.0.0/8,192.168.0.0/16'; }); after(() => { delete process.env.DM_TRUSTED_PROXIES; }); it('should accept valid CIDR ranges', async () => { const dm = new DM(); await dm.ready; expect(() => new TrustedProxy(dm)).to.not.throw(); }); }); describe('Configuration - valid IPv6', () => { before(() => { process.env.DM_TRUSTED_PROXIES = '::1,fe80::1'; }); after(() => { delete process.env.DM_TRUSTED_PROXIES; }); it('should accept valid IPv6 addresses', async () => { const dm = new DM(); await dm.ready; expect(() => new TrustedProxy(dm)).to.not.throw(); }); }); describe('Header Filtering - trusted proxy (localhost IPv4)', () => { let app: Express; before(async () => { // supertest uses 127.0.0.1 by default process.env.DM_TRUSTED_PROXIES = '127.0.0.1'; const dm = new DM(); await dm.ready; const trustedProxy = new TrustedProxy(dm); const headerInspector = new HeaderInspector(dm); await dm.registerPlugin('trustedProxy', trustedProxy); await dm.registerPlugin('headerInspector', headerInspector); app = dm.app; }); after(() => { delete process.env.DM_TRUSTED_PROXIES; }); it('should preserve X-Forwarded-For from trusted proxy', async () => { const response = await request(app) .get('/api/inspect') .set('X-Forwarded-For', '203.0.113.50'); expect(response.status).to.equal(200); expect(response.body.xForwardedFor).to.equal('203.0.113.50'); }); }); describe('Header Filtering - trusted proxy (localhost IPv6)', () => { let app: Express; before(async () => { // Also trust ::1 for IPv6 localhost process.env.DM_TRUSTED_PROXIES = '127.0.0.1,::1'; const dm = new DM(); await dm.ready; const trustedProxy = new TrustedProxy(dm); const headerInspector = new HeaderInspector(dm); await dm.registerPlugin('trustedProxy', trustedProxy); await dm.registerPlugin('headerInspector', headerInspector); app = dm.app; }); after(() => { delete process.env.DM_TRUSTED_PROXIES; }); it('should preserve X-Forwarded-For', async () => { const response = await request(app) .get('/api/inspect') .set('X-Forwarded-For', '203.0.113.50'); expect(response.status).to.equal(200); expect(response.body.xForwardedFor).to.equal('203.0.113.50'); }); }); describe('Header Filtering - untrusted source', () => { let app: Express; before(async () => { // Trust a different IP than localhost (supertest uses 127.0.0.1) process.env.DM_TRUSTED_PROXIES = '10.0.0.1'; const dm = new DM(); await dm.ready; const trustedProxy = new TrustedProxy(dm); const headerInspector = new HeaderInspector(dm); await dm.registerPlugin('trustedProxy', trustedProxy); await dm.registerPlugin('headerInspector', headerInspector); app = dm.app; }); after(() => { delete process.env.DM_TRUSTED_PROXIES; }); it('should remove X-Forwarded-For from untrusted source', async () => { const response = await request(app) .get('/api/inspect') .set('X-Forwarded-For', '203.0.113.50, 192.168.1.1'); expect(response.status).to.equal(200); // X-Forwarded-For should be removed since 127.0.0.1 is not trusted expect(response.body.xForwardedFor).to.equal(null); }); it('should allow requests without X-Forwarded-For', async () => { const response = await request(app).get('/api/inspect'); expect(response.status).to.equal(200); expect(response.body.xForwardedFor).to.equal(null); }); }); describe('Header Filtering - CIDR ranges', () => { let app: Express; before(async () => { // Trust entire 127.0.0.0/8 range process.env.DM_TRUSTED_PROXIES = '127.0.0.0/8'; const dm = new DM(); await dm.ready; const trustedProxy = new TrustedProxy(dm); const headerInspector = new HeaderInspector(dm); await dm.registerPlugin('trustedProxy', trustedProxy); await dm.registerPlugin('headerInspector', headerInspector); app = dm.app; }); after(() => { delete process.env.DM_TRUSTED_PROXIES; }); it('should trust CIDR ranges', async () => { const response = await request(app) .get('/api/inspect') .set('X-Forwarded-For', '203.0.113.50'); expect(response.status).to.equal(200); expect(response.body.xForwardedFor).to.equal('203.0.113.50'); }); }); describe('Header Filtering - IPv4-mapped IPv6', () => { let app: Express; before(async () => { // Trust 127.0.0.1 - should match ::ffff:127.0.0.1 process.env.DM_TRUSTED_PROXIES = '127.0.0.1'; const dm = new DM(); await dm.ready; const trustedProxy = new TrustedProxy(dm); const headerInspector = new HeaderInspector(dm); await dm.registerPlugin('trustedProxy', trustedProxy); await dm.registerPlugin('headerInspector', headerInspector); app = dm.app; }); after(() => { delete process.env.DM_TRUSTED_PROXIES; }); it('should handle IPv4-mapped IPv6 addresses', async () => { // supertest typically connects as 127.0.0.1 or ::ffff:127.0.0.1 const response = await request(app) .get('/api/inspect') .set('X-Forwarded-For', '203.0.113.50'); expect(response.status).to.equal(200); expect(response.body.xForwardedFor).to.equal('203.0.113.50'); }); }); describe('Integration - header filtering before other plugins', () => { let app: Express; before(async () => { // Untrusted source - X-Forwarded-For should be stripped process.env.DM_TRUSTED_PROXIES = '10.0.0.1'; const dm = new DM(); await dm.ready; const trustedProxy = new TrustedProxy(dm); const headerInspector = new HeaderInspector(dm); // TrustedProxy MUST be registered before other plugins await dm.registerPlugin('trustedProxy', trustedProxy); await dm.registerPlugin('headerInspector', headerInspector); app = dm.app; }); after(() => { delete process.env.DM_TRUSTED_PROXIES; }); it('should filter headers before other plugins see them', async () => { // Attacker tries to spoof their IP const response = await request(app) .get('/api/inspect') .set('X-Forwarded-For', '1.2.3.4'); expect(response.status).to.equal(200); // The spoofed header should be removed expect(response.body.xForwardedFor).to.equal(null); }); }); describe('Auth-User header from trusted proxy', () => { let app: Express; before(async () => { process.env.DM_TRUSTED_PROXIES = '127.0.0.1'; const dm = new DM(); await dm.ready; const trustedProxy = new TrustedProxy(dm); const headerInspector = new HeaderInspector(dm); await dm.registerPlugin('trustedProxy', trustedProxy); await dm.registerPlugin('headerInspector', headerInspector); app = dm.app; }); after(() => { delete process.env.DM_TRUSTED_PROXIES; }); it('should extract Auth-User from trusted proxy', async () => { const response = await request(app) .get('/api/inspect') .set('Auth-User', 'john.doe@example.com'); expect(response.status).to.equal(200); expect(response.body.trustedProxy).to.equal(true); expect(response.body.proxyAuthUser).to.equal('john.doe@example.com'); }); it('should mark request as trusted even without Auth-User', async () => { const response = await request(app).get('/api/inspect'); expect(response.status).to.equal(200); expect(response.body.trustedProxy).to.equal(true); expect(response.body.proxyAuthUser).to.equal(null); }); }); describe('Auth-User header from untrusted source', () => { let app: Express; before(async () => { // Trust a different IP than localhost process.env.DM_TRUSTED_PROXIES = '10.0.0.1'; const dm = new DM(); await dm.ready; const trustedProxy = new TrustedProxy(dm); const headerInspector = new HeaderInspector(dm); await dm.registerPlugin('trustedProxy', trustedProxy); await dm.registerPlugin('headerInspector', headerInspector); app = dm.app; }); after(() => { delete process.env.DM_TRUSTED_PROXIES; }); it('should NOT extract Auth-User from untrusted source', async () => { const response = await request(app) .get('/api/inspect') .set('Auth-User', 'attacker@evil.com'); expect(response.status).to.equal(200); expect(response.body.trustedProxy).to.equal(false); // Auth-User should NOT be trusted from untrusted source expect(response.body.proxyAuthUser).to.equal(null); }); }); describe('Custom auth header', () => { let app: Express; before(async () => { process.env.DM_TRUSTED_PROXIES = '127.0.0.1'; process.env.DM_TRUSTED_PROXY_AUTH_HEADER = 'X-Remote-User'; const dm = new DM(); await dm.ready; const trustedProxy = new TrustedProxy(dm); const headerInspector = new HeaderInspector(dm); await dm.registerPlugin('trustedProxy', trustedProxy); await dm.registerPlugin('headerInspector', headerInspector); app = dm.app; }); after(() => { delete process.env.DM_TRUSTED_PROXIES; delete process.env.DM_TRUSTED_PROXY_AUTH_HEADER; }); it('should use custom auth header name', async () => { const response = await request(app) .get('/api/inspect') .set('X-Remote-User', 'custom.user@example.com'); expect(response.status).to.equal(200); expect(response.body.trustedProxy).to.equal(true); expect(response.body.proxyAuthUser).to.equal('custom.user@example.com'); }); it('should ignore default Auth-User header when custom is configured', async () => { const response = await request(app) .get('/api/inspect') .set('Auth-User', 'should.be.ignored@example.com'); expect(response.status).to.equal(200); expect(response.body.trustedProxy).to.equal(true); // Auth-User should be ignored, X-Remote-User is not set expect(response.body.proxyAuthUser).to.equal(null); }); }); }); linagora-ldap-rest-16e557e/test/plugins/configApi.test.ts000066400000000000000000000152241522642357000234410ustar00rootroot00000000000000import { expect } from 'chai'; import supertest from 'supertest'; import { DM } from '../../src/bin'; import ConfigApi from '../../src/plugins/configApi'; import LdapFlatGeneric from '../../src/plugins/ldap/flatGeneric'; import LdapGroups from '../../src/plugins/ldap/groups'; import LdapOrganization from '../../src/plugins/ldap/organizations'; import Static from '../../src/plugins/static'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { skipIfMissingEnvVars, LDAP_ENV_VARS } from '../helpers/env'; describe('ConfigApi Plugin', () => { let dm: DM; before(function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS]); }); beforeEach(async () => { dm = new DM(); await dm.ready; }); it('should expose configuration endpoint', async () => { const configApi = new ConfigApi(dm); dm.registerPlugin('configApi', configApi); const request = supertest(dm.app); const response = await request .get('/api/v1/config') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body).to.have.property('apiPrefix'); expect(response.body).to.have.property('ldapBase'); expect(response.body).to.have.property('features'); }); it('should include flatResources when ldapFlatGeneric is loaded', async () => { // Register ldapFlatGeneric with a test schema const schemasPath = join( dirname(fileURLToPath(import.meta.url)), '..', '..', 'static', 'schemas' ); // Override config to load test schema dm.config.ldap_flat_schema = [ join(schemasPath, 'twake', 'nomenclature', 'twakeDeliveryMode.json'), ]; const flatGeneric = new LdapFlatGeneric(dm); dm.registerPlugin('ldapFlatGeneric', flatGeneric); const configApi = new ConfigApi(dm); dm.registerPlugin('configApi', configApi); const request = supertest(dm.app); const response = await request .get('/api/v1/config') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body.features.ldapFlatGeneric).to.exist; expect(response.body.features.ldapFlatGeneric.flatResources).to.be.an( 'array' ); expect( response.body.features.ldapFlatGeneric.flatResources.length ).to.be.greaterThan(0); const resource = response.body.features.ldapFlatGeneric.flatResources[0]; expect(resource).to.have.property('name'); expect(resource).to.have.property('singularName'); expect(resource).to.have.property('pluralName'); expect(resource).to.have.property('mainAttribute'); expect(resource).to.have.property('objectClass'); expect(resource).to.have.property('base'); expect(resource).to.have.property('schema'); expect(resource).to.have.property('endpoints'); }); it('should include groups configuration when ldapGroups is loaded', async function () { if (!process.env.DM_LDAP_GROUP_BASE) { this.skip(); } const groups = new LdapGroups(dm); dm.registerPlugin('ldapGroups', groups); const configApi = new ConfigApi(dm); dm.registerPlugin('configApi', configApi); const request = supertest(dm.app); const response = await request .get('/api/v1/config') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body.features.ldapGroups).to.exist; expect(response.body.features.ldapGroups.enabled).to.be.true; expect(response.body.features.ldapGroups).to.have.property('base'); expect(response.body.features.ldapGroups).to.have.property('endpoints'); expect(response.body.features.ldapGroups.endpoints).to.have.property( 'list' ); expect(response.body.features.ldapGroups.endpoints).to.have.property( 'addMember' ); }); it('should include organizations configuration when ldapOrganization is loaded', async function () { if (!process.env.DM_LDAP_TOP_ORGANIZATION) { this.skip(); } const organizations = new LdapOrganization(dm); dm.registerPlugin('ldapOrganizations', organizations); const configApi = new ConfigApi(dm); dm.registerPlugin('configApi', configApi); const request = supertest(dm.app); const response = await request .get('/api/v1/config') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body.features.ldapOrganizations).to.exist; expect(response.body.features.ldapOrganizations.enabled).to.be.true; expect(response.body.features.ldapOrganizations).to.have.property( 'topOrganization' ); expect(response.body.features.ldapOrganizations).to.have.property( 'endpoints' ); expect(response.body.features.ldapOrganizations.endpoints).to.have.property( 'getTop' ); // Regression: getTop must point at the actual /top route, not the // collection root which only accepts POST. expect(response.body.features.ldapOrganizations.endpoints.getTop).to.match( /\/v1\/ldap\/organizations\/top$/ ); expect(response.body.features.ldapOrganizations.endpoints).to.have.property( 'getSubnodes' ); }); it('should include schemaUrl when static plugin is loaded', async () => { const schemasPath = join( dirname(fileURLToPath(import.meta.url)), '..', '..', 'static', 'schemas' ); // Configure static plugin dm.config.static_path = join( dirname(fileURLToPath(import.meta.url)), '..', '..', 'static' ); dm.config.static_name = 'static'; // Load static plugin const staticPlugin = new Static(dm); dm.registerPlugin('static', staticPlugin); // Configure ldapFlatGeneric with a test schema dm.config.ldap_flat_schema = [ join(schemasPath, 'twake', 'nomenclature', 'twakeDeliveryMode.json'), ]; const flatGeneric = new LdapFlatGeneric(dm); dm.registerPlugin('ldapFlatGeneric', flatGeneric); const configApi = new ConfigApi(dm); dm.registerPlugin('configApi', configApi); const request = supertest(dm.app); const response = await request .get('/api/v1/config') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body.features.ldapFlatGeneric).to.exist; expect(response.body.features.ldapFlatGeneric.flatResources).to.be.an( 'array' ); expect( response.body.features.ldapFlatGeneric.flatResources.length ).to.be.greaterThan(0); const resource = response.body.features.ldapFlatGeneric.flatResources[0]; expect(resource).to.have.property('schemaUrl'); expect(resource.schemaUrl).to.equal( '/static/schemas/twake/nomenclature/twakeDeliveryMode.json' ); }); }); linagora-ldap-rest-16e557e/test/plugins/integrations/000077500000000000000000000000001522642357000227165ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/plugins/integrations/scimAuthzDynamic.test.ts000066400000000000000000000574121522642357000275310ustar00rootroot00000000000000/** * End-to-end integration tests that stack `core/auth/authzDynamic` * (LDAP-backed tokens + per-branch ACL) UNDER `core/scim` (SCIM 2.0 API), * exercising the exact deployment pattern the two plugins were designed * for: multi-tenant identity provisioning where each SCIM client is * cryptographically constrained to its own subtree. * * Setup emulated per test suite: * * dc=example,dc=com * ou=authz-tokens (token entries — authzDynamic cache) * cn=acme (Bearer "acme-secret") * cn=globex (Bearer "globex-secret") * ou=acme * ou=users (SCIM user base for tenant acme) * ou=groups (SCIM group base for tenant acme) * ou=globex * ou=users (SCIM user base for tenant globex) * ou=groups (SCIM group base for tenant globex) * * The SCIM base is resolved per-request via the `{user}` template fed with * `req.user` that authzDynamic populates from the matched token's tenant. */ import { expect } from 'chai'; import supertest from 'supertest'; import AuthzDynamic from '../../../src/plugins/auth/authzDynamic'; import Scim from '../../../src/plugins/scim/scim'; import { DM } from '../../../src/bin'; import { ssha } from '../../../src/plugins/auth/authzDynamicHash'; import type { Hooks } from '../../../src/hooks'; function wireHooks(server: DM, plugin: { hooks?: Hooks }): void { if (!plugin.hooks) return; for (const [name, fn] of Object.entries(plugin.hooks)) { if (!fn) continue; const list = (server.hooks[name] = server.hooks[name] || ([] as unknown[] as never)); (list as unknown as Array).push(fn as unknown); } } describe('SCIM + authzDynamic — multi-tenant E2E', function () { let server: DM; let authz: AuthzDynamic; let scim: Scim; let baseDn: string; let tokensOu: string; const acmeSecret = 'e2e-acme-secret-xxxxxxxx'; const globexSecret = 'e2e-globex-secret-yyyyyyyy'; // Third token: granted Users, denied Groups — exercises authzDynamic's // in-scope branch denial (vs. the SCIM-level isolation checked elsewhere). const usersOnlySecret = 'e2e-users-only-secret-zzzzzzzz'; // Env vars we mutate and must restore const envKeys = [ 'DM_AUTHZ_DYNAMIC_BASE', 'DM_AUTHZ_DYNAMIC_CACHE_TTL', 'DM_SCIM_USER_BASE_TEMPLATE', 'DM_SCIM_GROUP_BASE_TEMPLATE', 'DM_SCIM_USER_BASE', 'DM_SCIM_GROUP_BASE', 'DM_GROUP_SCHEMA', ] as const; const savedEnv: Record = {}; before(async function () { this.timeout(30000); if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_BASE ) { // eslint-disable-next-line no-console console.warn( 'Skipping SCIM+authzDynamic integration tests: LDAP env vars missing' ); this.skip(); return; } baseDn = process.env.DM_LDAP_BASE; tokensOu = `ou=e2e-tokens,${baseDn}`; // Snapshot env before mutating for (const k of envKeys) savedEnv[k] = process.env[k]; process.env.DM_AUTHZ_DYNAMIC_BASE = tokensOu; process.env.DM_AUTHZ_DYNAMIC_CACHE_TTL = '1'; // Per-tenant bases driven by req.user, populated by authzDynamic. process.env.DM_SCIM_USER_BASE_TEMPLATE = `ou=users,ou={user},${baseDn}`; process.env.DM_SCIM_GROUP_BASE_TEMPLATE = `ou=groups,ou={user},${baseDn}`; // Static bases are unused when templates fire, but we set them to a // distinct value so any accidental fall-back is visible in failures. delete process.env.DM_SCIM_USER_BASE; delete process.env.DM_SCIM_GROUP_BASE; // Disable the Twake group schema — plain groupOfNames is enough here. process.env.DM_GROUP_SCHEMA = ''; server = new DM(); // Auth first: AuthBase.api() adds the middleware at the head of the // stack, so SCIM routes registered after run AFTER auth. authz = new AuthzDynamic(server); authz.api(server.app); wireHooks(server, authz); scim = new Scim(server); await scim.api(server.app); wireHooks(server, scim); server.loadedPlugins['authzDynamic'] = authz; server.loadedPlugins['scim'] = scim; await server.ready; // Create the tenant tree. Tolerant to pre-existing entries between runs. const entries: Array<[string, Record]> = [ [ tokensOu, { objectClass: ['top', 'organizationalUnit'], ou: 'e2e-tokens', }, ], // acme tenant [ `ou=e2e-acme,${baseDn}`, { objectClass: ['top', 'organizationalUnit'], ou: 'e2e-acme' }, ], [ `ou=users,ou=e2e-acme,${baseDn}`, { objectClass: ['top', 'organizationalUnit'], ou: 'users' }, ], [ `ou=groups,ou=e2e-acme,${baseDn}`, { objectClass: ['top', 'organizationalUnit'], ou: 'groups' }, ], // globex tenant [ `ou=e2e-globex,${baseDn}`, { objectClass: ['top', 'organizationalUnit'], ou: 'e2e-globex' }, ], [ `ou=users,ou=e2e-globex,${baseDn}`, { objectClass: ['top', 'organizationalUnit'], ou: 'users' }, ], [ `ou=groups,ou=e2e-globex,${baseDn}`, { objectClass: ['top', 'organizationalUnit'], ou: 'groups' }, ], ]; for (const [dn, attrs] of entries) { try { await server.ldap.add(dn, attrs as never); } catch { /* may already exist */ } } // Two tokens, scoped to their tenant sub-trees. Use a delete-then-add // upsert so a previous run that failed before `after` could run doesn't // leave the suite stuck on EntryAlreadyExists. const upsert = async ( dn: string, attrs: Record ): Promise => { try { await server.ldap.delete(dn); } catch { /* not present — fine */ } await server.ldap.add(dn, attrs as never); }; await upsert(`cn=e2e-acme,${tokensOu}`, { objectClass: ['top', 'inetOrgPerson'], cn: 'e2e-acme', sn: 'e2e-acme', userPassword: ssha(acmeSecret), description: JSON.stringify({ tenant: 'e2e-acme', bases: [ { dn: `ou=users,ou=e2e-acme,${baseDn}`, read: true, write: true, delete: true, }, { dn: `ou=groups,ou=e2e-acme,${baseDn}`, read: true, write: true, delete: true, }, ], }), }); await upsert(`cn=e2e-globex,${tokensOu}`, { objectClass: ['top', 'inetOrgPerson'], cn: 'e2e-globex', sn: 'e2e-globex', userPassword: ssha(globexSecret), description: JSON.stringify({ tenant: 'e2e-globex', bases: [ { dn: `ou=users,ou=e2e-globex,${baseDn}`, read: true, write: true, delete: true, }, { dn: `ou=groups,ou=e2e-globex,${baseDn}`, read: true, write: true, delete: true, }, ], }), }); // Third token: users-only ACL, used below to exercise authzDynamic's // in-scope branch denial (no permission on groups → 403). await upsert(`cn=e2e-users-only,${tokensOu}`, { objectClass: ['top', 'inetOrgPerson'], cn: 'e2e-users-only', sn: 'e2e-users-only', userPassword: ssha(usersOnlySecret), description: JSON.stringify({ tenant: 'e2e-acme', bases: [ { dn: `ou=users,ou=e2e-acme,${baseDn}`, read: true, write: true, delete: true, }, // Deliberately NO entry for ou=groups — Group ops must be denied. ], }), }); await authz.reload(); }); after(async () => { if (server) { // Best-effort cleanup of anything this suite may have left behind. const cleanup = [ // Users / groups per tenant (catch-all) `uid=alice,ou=users,ou=e2e-acme,${baseDn}`, `uid=alice,ou=users,ou=e2e-globex,${baseDn}`, `uid=bob,ou=users,ou=e2e-acme,${baseDn}`, `cn=engineering,ou=groups,ou=e2e-acme,${baseDn}`, `cn=engineering,ou=groups,ou=e2e-globex,${baseDn}`, // Token entries `cn=e2e-acme,${tokensOu}`, `cn=e2e-globex,${tokensOu}`, `cn=e2e-users-only,${tokensOu}`, // Tenant OUs (order matters: children before parents) `ou=users,ou=e2e-acme,${baseDn}`, `ou=groups,ou=e2e-acme,${baseDn}`, `ou=e2e-acme,${baseDn}`, `ou=users,ou=e2e-globex,${baseDn}`, `ou=groups,ou=e2e-globex,${baseDn}`, `ou=e2e-globex,${baseDn}`, tokensOu, ]; for (const dn of cleanup) { try { await server.ldap.delete(dn); } catch { /* ignore */ } } } for (const k of envKeys) { if (savedEnv[k] === undefined) delete process.env[k]; else process.env[k] = savedEnv[k]; } }); afterEach(async () => { // Scrub any leftover test users/groups from the inner tests. const dns = [ `uid=alice,ou=users,ou=e2e-acme,${baseDn}`, `uid=alice,ou=users,ou=e2e-globex,${baseDn}`, `uid=bob,ou=users,ou=e2e-acme,${baseDn}`, `cn=engineering,ou=groups,ou=e2e-acme,${baseDn}`, `cn=engineering,ou=groups,ou=e2e-globex,${baseDn}`, ]; for (const dn of dns) { try { await server.ldap.delete(dn); } catch { /* ignore */ } } }); describe('Authentication gating', () => { it('/scim/v2/Users without a Bearer token returns 401', async () => { await supertest(server.app).get('/scim/v2/Users').expect(401); }); it('/scim/v2/Users with an unknown token returns 401', async () => { await supertest(server.app) .get('/scim/v2/Users') .set('Authorization', 'Bearer does-not-exist') .expect(401); }); it('discovery endpoints are also gated', async () => { // ServiceProviderConfig sits behind the auth middleware too. await supertest(server.app) .get('/scim/v2/ServiceProviderConfig') .expect(401); }); }); describe('Per-tenant base resolution via {user} template', () => { it('acme creates a User under ou=users,ou=e2e-acme', async () => { await supertest(server.app) .post('/scim/v2/Users') .set('Authorization', `Bearer ${acmeSecret}`) .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'alice', name: { familyName: 'Doe' }, }) .expect(201); // Direct LDAP read to confirm the DN actually lives under the acme branch. const result = await server.ldap.search( { paged: false, scope: 'base', attributes: ['uid'] }, `uid=alice,ou=users,ou=e2e-acme,${baseDn}` ); expect( (result as { searchEntries: unknown[] }).searchEntries ).to.have.lengthOf(1); }); it('globex creates its own alice without collision', async () => { // Alice already exists under acme (from the previous test is NOT // guaranteed: afterEach cleans up). Recreate for clarity. await supertest(server.app) .post('/scim/v2/Users') .set('Authorization', `Bearer ${acmeSecret}`) .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'alice', name: { familyName: 'Doe' }, }) .expect(201); // Same userName, different tenant → different DN, no conflict. await supertest(server.app) .post('/scim/v2/Users') .set('Authorization', `Bearer ${globexSecret}`) .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'alice', name: { familyName: 'Smith' }, }) .expect(201); const acmeDn = (await server.ldap.search( { paged: false, scope: 'base', attributes: ['sn'] }, `uid=alice,ou=users,ou=e2e-acme,${baseDn}` )) as unknown as { searchEntries: Array<{ sn: string }> }; const globexDn = (await server.ldap.search( { paged: false, scope: 'base', attributes: ['sn'] }, `uid=alice,ou=users,ou=e2e-globex,${baseDn}` )) as unknown as { searchEntries: Array<{ sn: string }> }; expect(acmeDn.searchEntries[0].sn).to.equal('Doe'); expect(globexDn.searchEntries[0].sn).to.equal('Smith'); }); it('each tenant sees only its own users in list', async () => { // Seed both tenants with the same userName. await supertest(server.app) .post('/scim/v2/Users') .set('Authorization', `Bearer ${acmeSecret}`) .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'alice', name: { familyName: 'AcmeSide' }, }) .expect(201); await supertest(server.app) .post('/scim/v2/Users') .set('Authorization', `Bearer ${globexSecret}`) .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'alice', name: { familyName: 'GlobexSide' }, }) .expect(201); const acmeList = await supertest(server.app) .get('/scim/v2/Users') .set('Authorization', `Bearer ${acmeSecret}`) .expect(200); const globexList = await supertest(server.app) .get('/scim/v2/Users') .set('Authorization', `Bearer ${globexSecret}`) .expect(200); const acmeFamilyNames = ( acmeList.body.Resources as Array<{ name?: { familyName?: string } }> ).map(r => r.name?.familyName); const globexFamilyNames = ( globexList.body.Resources as Array<{ name?: { familyName?: string } }> ).map(r => r.name?.familyName); expect(acmeFamilyNames).to.include('AcmeSide'); expect(acmeFamilyNames).to.not.include('GlobexSide'); expect(globexFamilyNames).to.include('GlobexSide'); expect(globexFamilyNames).to.not.include('AcmeSide'); }); it('each tenant filters by id in its own scope', async () => { await supertest(server.app) .post('/scim/v2/Users') .set('Authorization', `Bearer ${acmeSecret}`) .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'alice', name: { familyName: 'AcmeOnly' }, }) .expect(201); // Acme sees alice const found = await supertest(server.app) .get('/scim/v2/Users?filter=' + encodeURIComponent('id eq "alice"')) .set('Authorization', `Bearer ${acmeSecret}`) .expect(200); expect(found.body.totalResults).to.equal(1); // Globex does not const notFound = await supertest(server.app) .get('/scim/v2/Users?filter=' + encodeURIComponent('id eq "alice"')) .set('Authorization', `Bearer ${globexSecret}`) .expect(200); expect(notFound.body.totalResults).to.equal(0); }); }); describe('Cross-tenant isolation via SCIM base resolution and reference checks', () => { it('acme cannot GET a user that lives in globex by id (404)', async () => { // Seed bob only in globex. await supertest(server.app) .post('/scim/v2/Users') .set('Authorization', `Bearer ${globexSecret}`) .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'bob', name: { familyName: 'Smith' }, }) .expect(201); // Acme tries to look him up — lives in globex, should be invisible. await supertest(server.app) .get('/scim/v2/Users/bob') .set('Authorization', `Bearer ${acmeSecret}`) .expect(404); // cleanup try { await server.ldap.delete(`uid=bob,ou=users,ou=e2e-globex,${baseDn}`); } catch { /* ignore */ } }); it('acme cannot add a globex user as a member of an acme group', async () => { // Seed bob in globex, and an empty group in acme. await supertest(server.app) .post('/scim/v2/Users') .set('Authorization', `Bearer ${globexSecret}`) .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'bob', name: { familyName: 'Smith' }, }) .expect(201); const foreignDn = `uid=bob,ou=users,ou=e2e-globex,${baseDn}`; // Try to create an acme group referencing bob by full cross-tenant DN. const res = await supertest(server.app) .post('/scim/v2/Groups') .set('Authorization', `Bearer ${acmeSecret}`) .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], displayName: 'engineering', members: [{ value: foreignDn }], }) .expect(201); // The group was created, but the foreign DN MUST NOT have leaked in. const values = (res.body.members as Array<{ value: string }> | undefined) || []; for (const m of values) { expect(m.value, 'foreign DN leaked into group members').to.not.equal( foreignDn ); } // Cleanup try { await server.ldap.delete(`uid=bob,ou=users,ou=e2e-globex,${baseDn}`); } catch { /* ignore */ } }); it('acme cannot PATCH-add a globex user as a member of an acme group', async () => { // Seed bob in globex. await supertest(server.app) .post('/scim/v2/Users') .set('Authorization', `Bearer ${globexSecret}`) .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'bob', name: { familyName: 'Smith' }, }) .expect(201); // Create an empty acme group. await supertest(server.app) .post('/scim/v2/Groups') .set('Authorization', `Bearer ${acmeSecret}`) .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], displayName: 'engineering', }) .expect(201); const foreignDn = `uid=bob,ou=users,ou=e2e-globex,${baseDn}`; // SCIM Groups PATCH returns 200 with the updated resource on success. // We assert the 200 explicitly so a silent 4xx/5xx does NOT let the // test pass merely because the GET shows an untouched group. const patchRes = await supertest(server.app) .patch('/scim/v2/Groups/engineering') .set('Authorization', `Bearer ${acmeSecret}`) .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [ { op: 'add', path: 'members', value: [{ value: foreignDn }], }, ], }) .expect(200); // The PATCH response already shows members: the foreign DN must not // appear even in the immediate reply. const patchMembers = (patchRes.body.members as Array<{ value: string }> | undefined) || []; for (const m of patchMembers) { expect(m.value, 'foreign DN leaked in PATCH response').to.not.equal( foreignDn ); } // Re-fetch as a secondary confirmation (guards against a stale-read // cache masking the real LDAP state). const after = await supertest(server.app) .get('/scim/v2/Groups/engineering') .set('Authorization', `Bearer ${acmeSecret}`) .expect(200); const values = (after.body.members as Array<{ value: string }> | undefined) || []; for (const m of values) { expect(m.value).to.not.equal(foreignDn); } // Cleanup try { await server.ldap.delete(`uid=bob,ou=users,ou=e2e-globex,${baseDn}`); } catch { /* ignore */ } }); }); describe('authzDynamic in-scope branch denial (per-tenant ACL)', () => { // The `e2e-users-only` token lives in the acme tenant but its ACL only // grants permission on ou=users — NOT ou=groups. This exercises the // authz hook wiring: the tenant base resolver happily constructs a // Groups DN under the tenant branch, then the ldap*request hooks check // the ACL and must deny. it('users-only token CAN manage Users in its tenant (sanity)', async () => { await supertest(server.app) .post('/scim/v2/Users') .set('Authorization', `Bearer ${usersOnlySecret}`) .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'alice', name: { familyName: 'Scoped' }, }) .expect(201); }); it('users-only token is DENIED on Groups list with 403', async () => { const res = await supertest(server.app) .get('/scim/v2/Groups') .set('Authorization', `Bearer ${usersOnlySecret}`) .expect(403); expect(res.body.detail || res.body.error).to.match(/permission/i); }); it('users-only token is DENIED on Groups create with 403', async () => { const res = await supertest(server.app) .post('/scim/v2/Groups') .set('Authorization', `Bearer ${usersOnlySecret}`) .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], displayName: 'should-not-exist', }) .expect(403); // Neither the tenant name nor the internal marker should leak. const payload = JSON.stringify(res.body); expect(payload).to.not.match(/\[authz-forbidden\]/); }); }); describe('Discovery honesty under tenant auth', () => { it('ServiceProviderConfig is identical for both tenants', async () => { const a = await supertest(server.app) .get('/scim/v2/ServiceProviderConfig') .set('Authorization', `Bearer ${acmeSecret}`) .expect(200); const g = await supertest(server.app) .get('/scim/v2/ServiceProviderConfig') .set('Authorization', `Bearer ${globexSecret}`) .expect(200); // Capabilities are process-level, not per-tenant. expect(a.body.patch.supported).to.equal(g.body.patch.supported); expect(a.body.bulk.supported).to.equal(g.body.bulk.supported); expect(a.body.filter.supported).to.equal(g.body.filter.supported); expect(a.body.sort.supported).to.equal(g.body.sort.supported); }); }); describe('configApi surfaces both plugins', () => { it('authzDynamic features expose token count but no tenant DNs', () => { const data = authz.getConfigApiData(); expect(data.enabled).to.be.true; // Three seeded tokens: acme, globex, users-only. expect(data.tokenCount).to.equal(3); // No hashes or per-token DNs leak through the config API. expect(JSON.stringify(data)).to.not.match(/\{SSHA\}/); expect(JSON.stringify(data)).to.not.match(/cn=e2e-acme/); expect(JSON.stringify(data)).to.not.match(/cn=e2e-globex/); expect(JSON.stringify(data)).to.not.match(/cn=e2e-users-only/); }); it('SCIM features advertise the per-tenant base template', () => { const data = scim.getConfigApiData(); const baseResolution = data.baseResolution as { userBaseTemplate?: string; groupBaseTemplate?: string; hasBaseMap?: boolean; }; expect(baseResolution.userBaseTemplate).to.match(/\{user\}/); expect(baseResolution.groupBaseTemplate).to.match(/\{user\}/); expect(baseResolution.hasBaseMap).to.equal(false); }); }); }); linagora-ldap-rest-16e557e/test/plugins/integrations/scimAuthzPerBranch.test.ts000066400000000000000000000176231522642357000300110ustar00rootroot00000000000000/** * Regression test for issue #80: SCIM writes must honour `core/auth/authzPerBranch`. * * `authzPerBranch` enforces per-branch permissions through the * `ldap{add,modify,delete}request` hooks, which authorize against `req.user`. * SCIM writes used to call `ldap.add/modify/delete` WITHOUT threading the * request, so the hooks ran with no `req`, `shouldSkipAuthorization` returned * true, and the write was allowed unconditionally — an identity restricted to * one branch could create/delete entries anywhere via SCIM. * * These tests stack a header-based auth plugin (populates `req.user`) UNDER * `core/auth/authzPerBranch` UNDER `core/scim`, and assert that a SCIM write * to a branch the identity is not permitted to modify is rejected (403), * exactly like a directly issued LDAP write. */ import { expect } from 'chai'; import supertest from 'supertest'; import type { Response } from 'express'; import AuthzPerBranch from '../../../src/plugins/auth/authzPerBranch'; import Scim from '../../../src/plugins/scim/scim'; import { DM } from '../../../src/bin'; import AuthBase, { type DmRequest } from '../../../src/lib/auth/base'; import type { Role } from '../../../src/abstract/plugin'; import type { Hooks } from '../../../src/hooks'; /** Minimal auth plugin: identifies the caller from the `x-scim-user` header. */ class HeaderAuthPlugin extends AuthBase { name = 'headerAuth'; roles: Role[] = ['auth'] as const; authMethod(req: DmRequest, res: Response, next: () => void): void { const user = req.headers['x-scim-user']; if (user && typeof user === 'string') { req.user = user; return next(); } res.status(401).json({ error: 'Unauthorized' }); } } function wireHooks(server: DM, plugin: { hooks?: Hooks }): void { if (!plugin.hooks) return; for (const [name, fn] of Object.entries(plugin.hooks)) { if (!fn) continue; const list = (server.hooks[name] = server.hooks[name] || ([] as unknown[] as never)); (list as unknown as Array).push(fn as unknown); } } describe('SCIM + authzPerBranch — per-branch write enforcement (#80)', function () { let server: DM; let baseDn: string; let peopleBase: string; const envKeys = [ 'DM_AUTHZ_PER_BRANCH_CONFIG', 'DM_AUTHZ_PER_BRANCH_CACHE_TTL', 'DM_SCIM_USER_BASE', 'DM_SCIM_USER_BASE_TEMPLATE', 'DM_SCIM_GROUP_BASE', 'DM_GROUP_SCHEMA', ] as const; const savedEnv: Record = {}; before(async function () { this.timeout(30000); if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_BASE ) { // eslint-disable-next-line no-console console.warn( 'Skipping SCIM+authzPerBranch integration tests: LDAP env vars missing' ); this.skip(); return; } baseDn = process.env.DM_LDAP_BASE; peopleBase = `ou=people80,${baseDn}`; for (const k of envKeys) savedEnv[k] = process.env[k]; // writer: full rights on peopleBase. reader: read-only (no write/delete). process.env.DM_AUTHZ_PER_BRANCH_CONFIG = JSON.stringify({ default: { read: false, write: false, delete: false }, users: { writer: { [peopleBase]: { read: true, write: true, delete: true } }, reader: { [peopleBase]: { read: true, write: false, delete: false } }, }, groups: {}, }); process.env.DM_AUTHZ_PER_BRANCH_CACHE_TTL = '60'; process.env.DM_SCIM_USER_BASE = peopleBase; delete process.env.DM_SCIM_USER_BASE_TEMPLATE; delete process.env.DM_SCIM_GROUP_BASE; process.env.DM_GROUP_SCHEMA = ''; server = new DM(); // Auth middleware first so SCIM routes registered afterwards run after it. const auth = new HeaderAuthPlugin(server); auth.api(server.app); const authz = new AuthzPerBranch(server); wireHooks(server, authz); const scim = new Scim(server); await scim.api(server.app); wireHooks(server, scim); server.loadedPlugins['headerAuth'] = auth; server.loadedPlugins['authzPerBranch'] = authz; server.loadedPlugins['scim'] = scim; await server.ready; try { await server.ldap.add(peopleBase, { objectClass: ['top', 'organizationalUnit'], ou: 'people80', }); } catch { /* may already exist */ } }); after(async () => { if (server) { for (const dn of [ `uid=alice,${peopleBase}`, `uid=bob,${peopleBase}`, peopleBase, ]) { try { await server.ldap.delete(dn); } catch { /* ignore */ } } } for (const k of envKeys) { if (savedEnv[k] === undefined) delete process.env[k]; else process.env[k] = savedEnv[k]; } }); afterEach(async () => { for (const dn of [`uid=alice,${peopleBase}`, `uid=bob,${peopleBase}`]) { try { await server.ldap.delete(dn); } catch { /* ignore */ } } }); const createUser = (user: string, userName: string) => supertest(server.app) .post('/scim/v2/Users') .set('x-scim-user', user) .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName, name: { familyName: 'Doe' }, }); it('rejects unauthenticated SCIM access (401)', async () => { await supertest(server.app).get('/scim/v2/Users').expect(401); }); it('writer (write granted) CAN create a user via SCIM', async () => { await createUser('writer', 'alice').expect(201); const res = await server.ldap.search( { paged: false, scope: 'base', attributes: ['uid'] }, `uid=alice,${peopleBase}` ); expect( (res as { searchEntries: unknown[] }).searchEntries ).to.have.lengthOf(1); }); it('reader (write denied) is DENIED create with 403 — regression for #80', async () => { const res = await createUser('reader', 'bob').expect(403); // Marker must never leak to the client. expect(JSON.stringify(res.body)).to.not.match(/\[authz-forbidden\]/); // And nothing must have been written: a base-scoped search on the absent // entry raises noSuchObject (0x20), which equally proves bob was not created. let entries: unknown[] = []; try { const search = await server.ldap.search( { paged: false, scope: 'base', attributes: ['uid'] }, `uid=bob,${peopleBase}` ); entries = (search as { searchEntries: unknown[] }).searchEntries; } catch (err) { expect((err as { code?: number }).code).to.equal(32); // noSuchObject } expect(entries).to.have.lengthOf(0); }); it('reader (write denied) is DENIED update (PUT) with 403', async () => { await createUser('writer', 'alice').expect(201); // reader has read (GET resolves) but not write → modify hook denies (403). const res = await supertest(server.app) .put('/scim/v2/Users/alice') .set('x-scim-user', 'reader') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'alice', name: { familyName: 'Hax' }, }) .expect(403); expect(JSON.stringify(res.body)).to.not.match(/\[authz-forbidden\]/); }); it('reader (delete denied) CANNOT delete a user, writer CAN', async () => { await createUser('writer', 'alice').expect(201); // reader has read (so GET resolves) but not delete → 403, entry survives. await supertest(server.app) .delete('/scim/v2/Users/alice') .set('x-scim-user', 'reader') .expect(403); const stillThere = await server.ldap.search( { paged: false, scope: 'base', attributes: ['uid'] }, `uid=alice,${peopleBase}` ); expect( (stillThere as { searchEntries: unknown[] }).searchEntries ).to.have.lengthOf(1); // writer has delete → succeeds. await supertest(server.app) .delete('/scim/v2/Users/alice') .set('x-scim-user', 'writer') .expect(204); }); }); linagora-ldap-rest-16e557e/test/plugins/ldap/000077500000000000000000000000001522642357000211305ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/plugins/ldap/bulkImport.test.ts000066400000000000000000000305241522642357000246120ustar00rootroot00000000000000import { expect } from 'chai'; import { DM } from '../../../src/bin'; import LdapBulkImport from '../../../src/plugins/ldap/bulkImport'; import supertest from 'supertest'; import fs from 'fs'; import path from 'path'; import { skipIfMissingEnvVars, LDAP_ENV_VARS_WITH_ORG, } from '../../helpers/env'; describe('LDAP Bulk Import Plugin', function () { before(function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS_WITH_ORG]); }); let server: DM; let plugin: LdapBulkImport; let request: any; const getTestOrg1Dn = () => `ou=TestOrg1,${process.env.DM_LDAP_TOP_ORGANIZATION}`; const getTestOrg2Dn = () => `ou=TestOrg2,${process.env.DM_LDAP_TOP_ORGANIZATION}`; before(async function () { this.timeout(10000); // Create test schema const testSchemaPath = path.join( __dirname, '../../fixtures/bulk-import-schema.json' ); const testSchema = { base: process.env.DM_LDAP_BASE, mainAttribute: 'uid', properties: { objectClass: { type: 'array', fixed: true, default: ['top', 'twakeAccount', 'twakeWhitePages'], }, uid: { type: 'string', required: true, }, cn: { type: 'string', required: true, }, sn: { type: 'string', required: true, }, givenName: { type: 'string', }, mail: { type: 'string', }, userPassword: { type: 'string', }, twakeDepartmentLink: { type: 'string', role: ['organizationLink'], }, twakeDepartmentPath: { type: 'string', role: ['organizationPath'], }, }, }; // Ensure fixtures directory exists const fixturesDir = path.join(__dirname, '../../fixtures'); if (!fs.existsSync(fixturesDir)) { fs.mkdirSync(fixturesDir, { recursive: true }); } fs.writeFileSync(testSchemaPath, JSON.stringify(testSchema, null, 2)); // Set config process.env.DM_BULK_IMPORT_SCHEMAS = `testusers:${testSchemaPath}`; process.env.DM_BULK_IMPORT_MAX_FILE_SIZE = '1048576'; // 1MB process.env.DM_BULK_IMPORT_BATCH_SIZE = '50'; // Initialize server server = new DM(); plugin = new LdapBulkImport(server); await server.registerPlugin('bulkImport', plugin); // Setup API plugin.api(server.app); request = supertest(server.app); // Create test organizations await server.ldap.add(getTestOrg1Dn(), { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'TestOrg1', twakeDepartmentPath: 'TestOrg1', }); await server.ldap.add(getTestOrg2Dn(), { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'TestOrg2', twakeDepartmentPath: 'TestOrg2', }); }); after(async function () { // Clean up test organizations try { await server.ldap.delete(getTestOrg1Dn()); } catch (e) { // ignore } try { await server.ldap.delete(getTestOrg2Dn()); } catch (e) { // ignore } // Clean up test schema const testSchemaPath = path.join( __dirname, '../../fixtures/bulk-import-schema.json' ); if (fs.existsSync(testSchemaPath)) { fs.unlinkSync(testSchemaPath); } }); afterEach(async function () { // Clean up test users const testUsers = ['bulkuser1', 'bulkuser2', 'bulkuser3', 'invaliduser']; for (const uid of testUsers) { try { await server.ldap.delete(`uid=${uid},${process.env.DM_LDAP_BASE}`); } catch (e) { // ignore } } }); describe('Template Generation', () => { it('should generate CSV template with editable attributes', async () => { const res = await request.get( '/api/v1/ldap/bulk-import/testusers/template.csv' ); expect(res.status).to.equal(200); expect(res.headers['content-type']).to.include('text/csv'); expect(res.headers['content-disposition']).to.include( 'testusers-template.csv' ); const headers = res.text.trim().split(','); expect(headers).to.include('uid'); expect(headers).to.include('cn'); expect(headers).to.include('sn'); expect(headers).to.include('givenName'); expect(headers).to.include('mail'); expect(headers).to.include('organizationDn'); // Should NOT include fixed attributes expect(headers).to.not.include('objectClass'); // Should NOT include organizationLink/organizationPath (calculated) expect(headers).to.not.include('twakeDepartmentLink'); expect(headers).to.not.include('twakeDepartmentPath'); }); }); describe('Bulk Import', () => { it('should import users from valid CSV file', async function () { this.timeout(10000); const csvContent = [ 'uid,cn,sn,givenName,mail,userPassword,organizationDn', `bulkuser1,Bulk User 1,User1,Bulk,bulkuser1@test.org,Passw0rd!123,"${getTestOrg1Dn()}"`, `bulkuser2,Bulk User 2,User2,Bulk,bulkuser2@test.org,Passw0rd!456,"${getTestOrg2Dn()}"`, ].join('\n'); const res = await request .post('/api/v1/ldap/bulk-import/testusers') .attach('file', Buffer.from(csvContent), 'test.csv') .field('dryRun', 'false') .field('continueOnError', 'true'); expect(res.status).to.equal(200); expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('total', 2); expect(res.body).to.have.property('created', 2); expect(res.body).to.have.property('failed', 0); // Verify users were created const user1 = await server.ldap.search( { paged: false, scope: 'base' }, `uid=bulkuser1,${process.env.DM_LDAP_BASE}` ); expect((user1 as any).searchEntries[0].uid).to.equal('bulkuser1'); expect((user1 as any).searchEntries[0].twakeDepartmentLink).to.equal( getTestOrg1Dn() ); expect((user1 as any).searchEntries[0].twakeDepartmentPath).to.equal( 'TestOrg1' ); }); it('should handle dry run mode', async () => { const csvContent = [ 'uid,cn,sn,givenName,mail,userPassword,organizationDn', `bulkuser1,Bulk User 1,User1,Bulk,bulkuser1@test.org,Passw0rd!123,"${getTestOrg1Dn()}"`, ].join('\n'); const res = await request .post('/api/v1/ldap/bulk-import/testusers') .attach('file', Buffer.from(csvContent), 'test.csv') .field('dryRun', 'true'); expect(res.status).to.equal(200); expect(res.body).to.have.property('created', 1); // Verify user was NOT actually created try { await server.ldap.search( { paged: false, scope: 'base' }, `uid=bulkuser1,${process.env.DM_LDAP_BASE}` ); expect.fail('User should not exist in dry run mode'); } catch (error) { // Expected - user should not exist } }); it('should skip existing users when updateExisting is false', async function () { this.timeout(10000); // Create a user first await server.ldap.add(`uid=bulkuser1,${process.env.DM_LDAP_BASE}`, { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], uid: 'bulkuser1', cn: 'Existing User', sn: 'Existing', mail: 'existing@test.org', }); const csvContent = [ 'uid,cn,sn,givenName,mail,userPassword,organizationDn', `bulkuser1,Updated User,User1,Updated,updated@test.org,Newpass!123,"${getTestOrg1Dn()}"`, `bulkuser2,New User,User2,New,new@test.org,Passw0rd!123,"${getTestOrg1Dn()}"`, ].join('\n'); const res = await request .post('/api/v1/ldap/bulk-import/testusers') .attach('file', Buffer.from(csvContent), 'test.csv') .field('updateExisting', 'false'); expect(res.status).to.equal(200); expect(res.body).to.have.property('created', 1); expect(res.body).to.have.property('skipped', 1); // Verify first user was not updated const user1 = await server.ldap.search( { paged: false, scope: 'base' }, `uid=bulkuser1,${process.env.DM_LDAP_BASE}` ); expect((user1 as any).searchEntries[0].cn).to.equal('Existing User'); }); it('should update existing users when updateExisting is true', async function () { this.timeout(10000); // Create a user first await server.ldap.add(`uid=bulkuser1,${process.env.DM_LDAP_BASE}`, { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], uid: 'bulkuser1', cn: 'Existing User', sn: 'Existing', mail: 'existing@test.org', }); const csvContent = [ 'uid,cn,sn,givenName,mail,userPassword,organizationDn', `bulkuser1,Updated User,User1,Updated,updated@test.org,Newpass!123,"${getTestOrg1Dn()}"`, ].join('\n'); const res = await request .post('/api/v1/ldap/bulk-import/testusers') .attach('file', Buffer.from(csvContent), 'test.csv') .field('updateExisting', 'true'); expect(res.status).to.equal(200); expect(res.body).to.have.property('updated', 1); // Verify user was updated const user1 = await server.ldap.search( { paged: false, scope: 'base' }, `uid=bulkuser1,${process.env.DM_LDAP_BASE}` ); expect((user1 as any).searchEntries[0].cn).to.equal('Updated User'); }); it('should handle errors and continue when continueOnError is true', async () => { const csvContent = [ 'uid,cn,sn,givenName,mail,userPassword,organizationDn', `bulkuser1,Valid User,User1,Valid,valid@test.org,Passw0rd!123,"${getTestOrg1Dn()}"`, `invaliduser,Invalid User,User2,Invalid,invalid@test.org,Passw0rd!123,"ou=nonexistent,dc=invalid"`, // Invalid org `bulkuser2,Valid User 2,User2,Valid,valid2@test.org,Passw0rd!456,"${getTestOrg1Dn()}"`, ].join('\n'); const res = await request .post('/api/v1/ldap/bulk-import/testusers') .attach('file', Buffer.from(csvContent), 'test.csv') .field('continueOnError', 'true'); expect(res.status).to.equal(200); expect(res.body).to.have.property('created', 2); expect(res.body).to.have.property('failed', 1); expect(res.body.errors).to.have.lengthOf(1); expect(res.body.errors[0]).to.have.property('line', 3); }); it('should validate required attributes', async () => { const csvContent = [ 'uid,cn,givenName,mail,organizationDn', // Missing 'sn' `bulkuser1,Bulk User 1,Bulk,bulkuser1@test.org,"${getTestOrg1Dn()}"`, ].join('\n'); const res = await request .post('/api/v1/ldap/bulk-import/testusers') .attach('file', Buffer.from(csvContent), 'test.csv'); expect(res.status).to.equal(200); expect(res.body).to.have.property('failed', 1); expect(res.body.errors[0].error).to.include( 'Missing required attribute: sn' ); }); it('should handle multi-value attributes', async function () { this.timeout(10000); const csvContent = [ 'uid,cn,sn,givenName,mail,userPassword,organizationDn', `bulkuser1,Bulk User 1,User1,Bulk,bulkuser1@test.org;bulkuser1-alt@test.org,Passw0rd!123,"${getTestOrg1Dn()}"`, ].join('\n'); const res = await request .post('/api/v1/ldap/bulk-import/testusers') .attach('file', Buffer.from(csvContent), 'test.csv'); expect(res.status).to.equal(200); expect(res.body).to.have.property('created', 1); // Verify multi-value attribute const user1 = await server.ldap.search( { paged: false, scope: 'base' }, `uid=bulkuser1,${process.env.DM_LDAP_BASE}` ); const mail = (user1 as any).searchEntries[0].mail; expect(mail).to.be.an('array'); expect(mail).to.have.lengthOf(2); expect(mail).to.include('bulkuser1@test.org'); expect(mail).to.include('bulkuser1-alt@test.org'); }); it('should reject non-CSV files', async () => { const res = await request .post('/api/v1/ldap/bulk-import/testusers') .attach('file', Buffer.from('not a csv'), 'test.txt'); expect(res.status).to.equal(500); }); it('should return 400 when no file is uploaded', async () => { const res = await request.post('/api/v1/ldap/bulk-import/testusers'); expect(res.status).to.equal(400); expect(res.body.error).to.include('No file uploaded'); }); }); }); linagora-ldap-rest-16e557e/test/plugins/ldap/departmentSync.test.ts000066400000000000000000000346371522642357000254730ustar00rootroot00000000000000import { expect } from 'chai'; import LdapDepartmentSync from '../../../src/plugins/ldap/departmentSync'; import { DM } from '../../../src/bin'; import type { SearchResult } from 'ldapts'; import { skipIfMissingEnvVars, LDAP_ENV_VARS_WITH_ORG, } from '../../helpers/env'; describe('LDAP Department Sync Plugin', function () { before(function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS_WITH_ORG]); }); let server: DM; let plugin: LdapDepartmentSync; let DM_LDAP_TOP_ORGANIZATION: string; let DM_LDAP_BASE: string; let DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE: string | undefined; let DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE: string | undefined; let testOrgDn: string; let testSubOrg1Dn: string; let testSubOrg2Dn: string; let movedOrgDn: string; let testUserDn: string; let testUser2Dn: string; let testGroupDn: string; before(async () => { DM_LDAP_TOP_ORGANIZATION = process.env.DM_LDAP_TOP_ORGANIZATION!; DM_LDAP_BASE = process.env.DM_LDAP_BASE!; DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE = process.env.DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE; DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE = process.env.DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE; testOrgDn = `ou=SyncTestOrg,${DM_LDAP_TOP_ORGANIZATION}`; testSubOrg1Dn = `ou=SubOrg1,${testOrgDn}`; testSubOrg2Dn = `ou=SubOrg2,${testSubOrg1Dn}`; movedOrgDn = `ou=SyncTestOrgMoved,${DM_LDAP_TOP_ORGANIZATION}`; testUserDn = `uid=synctestuser,${DM_LDAP_BASE}`; testUser2Dn = `uid=synctestuser2,${DM_LDAP_BASE}`; testGroupDn = `cn=synctestgroup,${DM_LDAP_BASE}`; server = new DM(); await server.ready; plugin = new LdapDepartmentSync(server); }); afterEach(async () => { // Clean up all test entries const cleanupEntries = [ testUser2Dn, testUserDn, testGroupDn, `ou=SubOrg2,ou=SubOrg1,${movedOrgDn}`, `ou=SubOrg1,${movedOrgDn}`, testSubOrg2Dn, testSubOrg1Dn, movedOrgDn, testOrgDn, ]; for (const dn of cleanupEntries) { try { await server.ldap.delete(dn); } catch (e) { // ignore } } }); describe('constructor', () => { it('should initialize with default attribute names', () => { expect(plugin.name).to.equal('ldapDepartmentSync'); expect(plugin.roles).to.deep.equal(['consistency']); }); it('should use configured attribute names', () => { const linkAttr = (server.config.ldap_organization_link_attribute as string) || 'twakeDepartmentLink'; const pathAttr = (server.config.ldap_organization_path_attribute as string) || 'twakeDepartmentPath'; expect(plugin['linkAttr']).to.equal(linkAttr); expect(plugin['pathAttr']).to.equal(pathAttr); }); }); describe('ldaprenamedone hook', () => { it('should skip non-organization renames', async () => { const oldDn = `uid=testuser,${DM_LDAP_BASE}`; const newDn = `uid=testuser2,${DM_LDAP_BASE}`; // Should not throw error await plugin.hooks.ldaprenamedone?.([oldDn, newDn]); }); it('should update resources directly linked to renamed organization', async () => { const linkAttr = DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE || 'twakeDepartmentLink'; const pathAttr = DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE || 'twakeDepartmentPath'; // Create organization await server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'SyncTestOrg', [pathAttr]: '/SyncTestOrg', }); // Create user linked to organization await server.ldap.add(testUserDn, { objectClass: ['twakeAccount', 'twakeWhitePages', 'top'], uid: 'synctestuser', cn: 'Sync Test User', sn: 'User', mail: 'synctestuser@example.org', [linkAttr]: testOrgDn, [pathAttr]: '/SyncTestOrg', }); // Rename organization await server.ldap.rename(testOrgDn, movedOrgDn); // Update the organization's path attribute (normally done by organization plugin) await server.ldap.modify(movedOrgDn, { replace: { [pathAttr]: '/SyncTestOrgMoved' }, }); // Trigger the hook await plugin.hooks.ldaprenamedone?.([testOrgDn, movedOrgDn]); // Verify user's link was updated const userResult = (await server.ldap.search( { paged: false, scope: 'base', attributes: [linkAttr, pathAttr] }, testUserDn )) as SearchResult; const user = userResult.searchEntries[0]; const userLink = Array.isArray(user[linkAttr]) ? String(user[linkAttr][0]) : String(user[linkAttr]); const userPath = Array.isArray(user[pathAttr]) ? String(user[pathAttr][0]) : String(user[pathAttr]); expect(userLink).to.equal(movedOrgDn); expect(userPath).to.equal('/SyncTestOrgMoved'); }); it('should update resources linked to sub-organizations when parent is renamed', async () => { const linkAttr = DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE || 'twakeDepartmentLink'; const pathAttr = DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE || 'twakeDepartmentPath'; // Create organization hierarchy await server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'SyncTestOrg', [pathAttr]: '/SyncTestOrg', }); await server.ldap.add(testSubOrg1Dn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'SubOrg1', [pathAttr]: '/SyncTestOrg/SubOrg1', }); // Create user linked to sub-organization await server.ldap.add(testUserDn, { objectClass: ['twakeAccount', 'twakeWhitePages', 'top'], uid: 'synctestuser', cn: 'Sync Test User', sn: 'User', [linkAttr]: testSubOrg1Dn, [pathAttr]: '/SyncTestOrg/SubOrg1', }); // Rename parent organization (this moves sub-org automatically) await server.ldap.rename(testOrgDn, movedOrgDn); // The new sub-org DN after parent rename const movedSubOrg1Dn = `ou=SubOrg1,${movedOrgDn}`; // Update the organizations' path attributes (normally done by organization plugin) await server.ldap.modify(movedOrgDn, { replace: { [pathAttr]: '/SyncTestOrgMoved' }, }); await server.ldap.modify(movedSubOrg1Dn, { replace: { [pathAttr]: '/SyncTestOrgMoved/SubOrg1' }, }); // Trigger the hook await plugin.hooks.ldaprenamedone?.([testOrgDn, movedOrgDn]); // Verify user's link was updated to new sub-org DN const userResult = (await server.ldap.search( { paged: false, scope: 'base', attributes: [linkAttr, pathAttr] }, testUserDn )) as SearchResult; const user = userResult.searchEntries[0]; const userLink = Array.isArray(user[linkAttr]) ? String(user[linkAttr][0]) : String(user[linkAttr]); const userPath = Array.isArray(user[pathAttr]) ? String(user[pathAttr][0]) : String(user[pathAttr]); expect(userLink).to.equal(movedSubOrg1Dn); expect(userPath).to.equal('/SyncTestOrgMoved/SubOrg1'); }); it('should update multiple resources at once', async () => { const linkAttr = DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE || 'twakeDepartmentLink'; const pathAttr = DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE || 'twakeDepartmentPath'; // Create organization await server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'SyncTestOrg', [pathAttr]: '/SyncTestOrg', }); // Create multiple resources linked to organization await server.ldap.add(testUserDn, { objectClass: ['twakeAccount', 'twakeWhitePages', 'top'], uid: 'synctestuser', cn: 'Sync Test User', sn: 'User', mail: 'synctestuser@example.org', [linkAttr]: testOrgDn, [pathAttr]: '/SyncTestOrg', }); await server.ldap.add(testUser2Dn, { objectClass: ['twakeAccount', 'twakeWhitePages', 'top'], uid: 'synctestuser2', cn: 'Sync Test User 2', sn: 'User', mail: 'synctestuser2@example.org', [linkAttr]: testOrgDn, [pathAttr]: '/SyncTestOrg', }); await server.ldap.add(testGroupDn, { objectClass: ['groupOfNames', 'twakeStaticGroup', 'top'], cn: 'synctestgroup', member: testUserDn, [linkAttr]: testOrgDn, [pathAttr]: '/SyncTestOrg', }); // Rename organization await server.ldap.rename(testOrgDn, movedOrgDn); // Update the organization's path attribute (normally done by organization plugin) await server.ldap.modify(movedOrgDn, { replace: { [pathAttr]: '/SyncTestOrgMoved' }, }); // Trigger the hook await plugin.hooks.ldaprenamedone?.([testOrgDn, movedOrgDn]); // Verify all resources were updated const entries = [testUserDn, testUser2Dn, testGroupDn]; for (const dn of entries) { const result = (await server.ldap.search( { paged: false, scope: 'base', attributes: [linkAttr, pathAttr] }, dn )) as SearchResult; const entry = result.searchEntries[0]; const entryLink = Array.isArray(entry[linkAttr]) ? String(entry[linkAttr][0]) : String(entry[linkAttr]); const entryPath = Array.isArray(entry[pathAttr]) ? String(entry[pathAttr][0]) : String(entry[pathAttr]); expect(entryLink).to.equal(movedOrgDn); expect(entryPath).to.equal('/SyncTestOrgMoved'); } }); it('should handle deep organizational hierarchy', async () => { const linkAttr = DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE || 'twakeDepartmentLink'; const pathAttr = DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE || 'twakeDepartmentPath'; // Create deep hierarchy: testOrg -> SubOrg1 -> SubOrg2 await server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'SyncTestOrg', [pathAttr]: '/SyncTestOrg', }); await server.ldap.add(testSubOrg1Dn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'SubOrg1', [pathAttr]: '/SyncTestOrg/SubOrg1', }); await server.ldap.add(testSubOrg2Dn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'SubOrg2', [pathAttr]: '/SyncTestOrg/SubOrg1/SubOrg2', }); // Create user linked to deepest sub-organization await server.ldap.add(testUserDn, { objectClass: ['twakeAccount', 'twakeWhitePages', 'top'], uid: 'synctestuser', cn: 'Sync Test User', sn: 'User', [linkAttr]: testSubOrg2Dn, [pathAttr]: '/SyncTestOrg/SubOrg1/SubOrg2', }); // Rename top-level organization await server.ldap.rename(testOrgDn, movedOrgDn); // New DNs after parent rename const movedSubOrg1Dn = `ou=SubOrg1,${movedOrgDn}`; const movedSubOrg2Dn = `ou=SubOrg2,${movedSubOrg1Dn}`; // Update organizations' path attributes (normally done by organization plugin) await server.ldap.modify(movedOrgDn, { replace: { [pathAttr]: '/SyncTestOrgMoved' }, }); await server.ldap.modify(movedSubOrg1Dn, { replace: { [pathAttr]: '/SyncTestOrgMoved/SubOrg1' }, }); await server.ldap.modify(movedSubOrg2Dn, { replace: { [pathAttr]: '/SyncTestOrgMoved/SubOrg1/SubOrg2' }, }); // Trigger the hook await plugin.hooks.ldaprenamedone?.([testOrgDn, movedOrgDn]); // Verify user's link was updated to new deep sub-org DN const userResult = (await server.ldap.search( { paged: false, scope: 'base', attributes: [linkAttr, pathAttr] }, testUserDn )) as SearchResult; const user = userResult.searchEntries[0]; const userLink = Array.isArray(user[linkAttr]) ? String(user[linkAttr][0]) : String(user[linkAttr]); const userPath = Array.isArray(user[pathAttr]) ? String(user[pathAttr][0]) : String(user[pathAttr]); expect(userLink).to.equal(movedSubOrg2Dn); expect(userPath).to.equal('/SyncTestOrgMoved/SubOrg1/SubOrg2'); }); it('should not fail when organization has no linked resources', async () => { const pathAttr = DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE || 'twakeDepartmentPath'; // Create organization without any linked resources await server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'SyncTestOrg', [pathAttr]: '/SyncTestOrg', }); // Rename organization await server.ldap.rename(testOrgDn, movedOrgDn); // Trigger the hook (should not throw) await plugin.hooks.ldaprenamedone?.([testOrgDn, movedOrgDn]); }); it('should handle organization path without attribute', async () => { const linkAttr = DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE || 'twakeDepartmentLink'; const pathAttr = DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE || 'twakeDepartmentPath'; // Create organization without path attribute // Note: twakeDepartment requires twakeDepartmentPath, so we use basic organizationalUnit await server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'top'], ou: 'SyncTestOrg', }); // Create user linked to organization await server.ldap.add(testUserDn, { objectClass: ['twakeAccount', 'twakeWhitePages', 'top'], uid: 'synctestuser', cn: 'Sync Test User', sn: 'User', mail: 'synctestuser@example.org', [linkAttr]: testOrgDn, [pathAttr]: '/SyncTestOrg', }); // Rename organization await server.ldap.rename(testOrgDn, movedOrgDn); // Trigger the hook (should fallback to constructing path from ou attribute) await plugin.hooks.ldaprenamedone?.([testOrgDn, movedOrgDn]); // Verify user's link was updated const userResult = (await server.ldap.search( { paged: false, scope: 'base', attributes: [linkAttr, pathAttr] }, testUserDn )) as SearchResult; const user = userResult.searchEntries[0]; const userLink = Array.isArray(user[linkAttr]) ? String(user[linkAttr][0]) : String(user[linkAttr]); expect(userLink).to.equal(movedOrgDn); // Path should be constructed from ou attribute expect(user[pathAttr]).to.exist; }); }); }); linagora-ldap-rest-16e557e/test/plugins/ldap/externalUsersInGroups.test.ts000066400000000000000000000103071522642357000270120ustar00rootroot00000000000000import { expect } from 'chai'; import LdapGroups from '../../../src/plugins/ldap/groups'; import { DM } from '../../../src/bin'; import ExternalUsersInGroups from '../../../src/plugins/ldap/externalUsersInGroups'; import { SearchResult } from 'ldapts'; const { DM_LDAP_GROUP_BASE } = process.env; process.env.DM_GROUP_SCHEMA = ''; describe('External users in groups', function () { // Skip all tests if required env vars are not set if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_GROUP_BASE ) { // eslint-disable-next-line no-console console.warn( 'Skipping ldap/groups tests: DM_GROUP_BASE and LDAP_LIB env vars are required' ); // @ts-ignore this.skip?.(); return; } let server: DM; let plugin: LdapGroups; const user1 = `mail=toto@toto.org,${process.env.DM_EXTERNAL_MEMBERS_BRANCH}`; before(async () => { server = new DM(); await server.ready; plugin = new LdapGroups(server); await server.registerPlugin('ldapGroups', plugin); await server.registerPlugin( 'externalUsersInGroups', new ExternalUsersInGroups(server) ); }); afterEach(async () => { try { await plugin.deleteGroup('testgroup'); } catch (e) { // ignore } try { await plugin.ldap.delete(user1); } catch (e) { // ignore } }); it('should load externalUsersInGroups', () => { expect(plugin.constructor.name).to.equal('LdapGroups'); }); it('should accept external member in group', async () => { await plugin.addGroup('testgroup', [user1]); expect(await plugin.searchGroupsByName('testgroup')).to.deep.equal({ testgroup: { dn: `cn=testgroup,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup', member: [user1], }, }); expect( ((await plugin.ldap.search({ paged: false }, user1)) as SearchResult) .searchEntries[0] ) .to.have.property('mail') .that.equals('toto@toto.org'); }); describe('Mail domain validation', function () { // Skip if mail_domain is not configured if (!process.env.DM_MAIL_DOMAIN) { // eslint-disable-next-line no-console console.warn( 'Skipping mail domain validation tests: DM_MAIL_DOMAIN not configured' ); // @ts-ignore this.skip?.(); return; } const managedDomain = process.env.DM_MAIL_DOMAIN.split(',')[0]; const managedUser = `mail=internal@${managedDomain},${process.env.DM_EXTERNAL_MEMBERS_BRANCH}`; afterEach(async () => { try { await plugin.deleteGroup('testgroup2'); } catch (e) { // ignore } try { await plugin.ldap.delete(managedUser); } catch (e) { // ignore } }); it('should reject external member with managed domain', async () => { try { await plugin.addGroup('testgroup2', [managedUser]); expect.fail('Should reject managed domain'); } catch (e) { expect((e as Error).message).to.match( /Cannot create external user with managed domain/ ); } }); it('should accept external member with non-managed domain', async () => { const externalUser = `mail=external@external-domain.org,${process.env.DM_EXTERNAL_MEMBERS_BRANCH}`; await plugin.addGroup('testgroup2', [externalUser]); expect(await plugin.searchGroupsByName('testgroup2')).to.deep.equal({ testgroup2: { dn: `cn=testgroup2,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup2', member: [externalUser], }, }); expect( ( (await plugin.ldap.search( { paged: false }, externalUser )) as SearchResult ).searchEntries[0] ) .to.have.property('mail') .that.equals('external@external-domain.org'); // Delete user - the hook should automatically remove it from groups await plugin.ldap.delete(externalUser); // Verify user was removed from group by the hook expect(await plugin.searchGroupsByName('testgroup2')).to.deep.equal({ testgroup2: { dn: `cn=testgroup2,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup2', member: [], }, }); }); }); }); linagora-ldap-rest-16e557e/test/plugins/ldap/fixedAttributes.test.ts000066400000000000000000000127101522642357000256250ustar00rootroot00000000000000import { expect } from 'chai'; import LdapFlatGeneric from '../../../src/plugins/ldap/flatGeneric'; import { DM } from '../../../src/bin'; const { DM_LDAP_BASE } = process.env; const USER_BRANCH = `ou=users,${DM_LDAP_BASE}`; describe('Fixed attributes validation', function () { // Skip all tests if required env vars are not set if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_BASE ) { console.warn( 'Skipping fixed attributes tests: DM_LDAP_BASE and LDAP credentials are required' ); // @ts-ignore this.skip?.(); return; } let server: DM; let genericPlugin: LdapFlatGeneric; let plugin: any; before(async function () { this.timeout(5000); process.env.DM_LDAP_FLAT_SCHEMA = './static/schemas/standard/users.json'; server = new DM(); genericPlugin = new LdapFlatGeneric(server); plugin = genericPlugin.instances[0]; }); afterEach(async () => { try { await plugin.deleteEntry('testfixed').catch(() => {}); } catch (e) { // Ignore cleanup errors } }); describe('objectClass as fixed attribute', () => { it('should automatically set objectClass to default value on creation', async function () { this.timeout(5000); // Create user without specifying objectClass (uid is passed separately) await plugin.addEntry('testfixed', { cn: 'Test Fixed', sn: 'Fixed', }); // Construct the DN const dn = `${plugin.mainAttribute}=testfixed,${plugin.base}`; // Retrieve the created entry directly from LDAP const searchResult = await plugin.ldap.search( { paged: false, scope: 'base', attributes: ['*'], }, dn ); expect(searchResult.searchEntries).to.have.lengthOf(1); const user = searchResult.searchEntries[0]; expect(user.objectClass).to.be.an('array'); expect(user.objectClass).to.include.members([ 'top', 'inetOrgPerson', 'organizationalPerson', 'person', ]); // Cleanup await plugin.deleteEntry('testfixed'); }); it('should reject creation with different objectClass value', async function () { this.timeout(5000); try { await plugin.addEntry('testfixed', { cn: 'Test Fixed', sn: 'Fixed', objectClass: ['top', 'person'], // Wrong objectClass }); expect.fail('Should have thrown an error'); } catch (err: any) { // Error message should indicate the objectClass is fixed expect(err.message).to.match(/fixed|objectClass/i); } }); it('should reject modification of objectClass via replace', async function () { this.timeout(5000); // First create a valid user await plugin.addEntry('testfixed', { cn: 'Test Fixed', sn: 'Fixed', }); // Try to modify objectClass try { await plugin.modifyEntry('testfixed', { replace: { objectClass: ['top', 'person'], }, }); expect.fail('Should have thrown an error'); } catch (err: any) { expect(err.message).to.match(/fixed and cannot be modified/i); } // Cleanup await plugin.deleteEntry('testfixed'); }); it('should reject modification of objectClass via add', async function () { this.timeout(5000); // First create a valid user await plugin.addEntry('testfixed', { cn: 'Test Fixed', sn: 'Fixed', }); // Try to add to objectClass try { await plugin.modifyEntry('testfixed', { add: { objectClass: ['posixAccount'], }, }); expect.fail('Should have thrown an error'); } catch (err: any) { expect(err.message).to.match(/fixed and cannot be modified/i); } // Cleanup await plugin.deleteEntry('testfixed'); }); it('should reject deletion of objectClass', async function () { this.timeout(5000); // First create a valid user await plugin.addEntry('testfixed', { cn: 'Test Fixed', sn: 'Fixed', }); // Try to delete objectClass try { await plugin.modifyEntry('testfixed', { delete: ['objectClass'], }); expect.fail('Should have thrown an error'); } catch (err: any) { expect(err.message).to.match(/fixed and cannot be deleted/i); } // Cleanup await plugin.deleteEntry('testfixed'); }); it('should allow modification of non-fixed attributes', async function () { this.timeout(5000); // First create a valid user await plugin.addEntry('testfixed', { cn: 'Test Fixed', sn: 'Fixed', }); // Modify a non-fixed attribute - should work await plugin.modifyEntry('testfixed', { replace: { cn: 'Modified Name', }, }); // Construct the DN const dn = `${plugin.mainAttribute}=testfixed,${plugin.base}`; // Verify the modification via direct LDAP search const searchResult = await plugin.ldap.search( { paged: false, scope: 'base', attributes: ['cn'], }, dn ); expect(searchResult.searchEntries).to.have.lengthOf(1); const user = searchResult.searchEntries[0]; expect(user.cn).to.equal('Modified Name'); // Cleanup await plugin.deleteEntry('testfixed'); }); }); }); linagora-ldap-rest-16e557e/test/plugins/ldap/flatGeneric.test.ts000066400000000000000000000072031522642357000247030ustar00rootroot00000000000000import { expect } from 'chai'; import supertest from 'supertest'; import LdapFlatGeneric from '../../../src/plugins/ldap/flatGeneric'; import { DM } from '../../../src/bin'; import { skipIfMissingEnvVars, LDAP_ENV_VARS } from '../../helpers/env'; describe('LdapFlatGeneric plugin', function () { before(function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS]); }); let server: DM; let plugin: LdapFlatGeneric; let request: any; before(async function () { this.timeout(5000); process.env.DM_LDAP_FLAT_SCHEMA = './static/schemas/twake/nomenclature/twakeTitle.json,./static/schemas/twake/nomenclature/twakeListType.json'; server = new DM(); await server.ready; plugin = new LdapFlatGeneric(server); await server.registerPlugin('ldapFlatGeneric', plugin); request = supertest(server.app); }); after(async () => { // Cleanup is handled automatically }); describe('constructor', () => { it('should create instances from schemas', () => { expect(plugin.instances).to.have.lengthOf(2); expect(plugin.instances[0].name).to.equal('ldapFlat:twakeTitle'); expect(plugin.instances[1].name).to.equal('ldapFlat:twakeListType'); }); }); describe('API endpoints', () => { it('should list titles', async () => { const res = await request.get('/api/v1/ldap/titles'); expect(res.status).to.equal(200); expect(res.body).to.be.an('object'); expect(res.body).to.have.property('Dr'); expect(res.body).to.have.property('Mr'); }); it('should list listTypes', async () => { const res = await request.get('/api/v1/ldap/listTypes'); expect(res.status).to.equal(200); expect(res.body).to.be.an('object'); expect(res.body).to.have.property('openList'); expect(res.body).to.have.property('memberRestrictedList'); }); it('should filter titles by name', async () => { const res = await request.get( '/api/v1/ldap/titles?match=Dr&attribute=cn' ); expect(res.status).to.equal(200); expect(res.body).to.be.an('object'); expect(res.body).to.have.property('Dr'); }); }); describe('CRUD operations', () => { afterEach(async () => { try { await plugin.instances[0].deleteEntry('TestTitle'); } catch (e) { // Ignore } }); it('should create a new title', async () => { const res = await request .post('/api/v1/ldap/titles') .send({ cn: 'TestTitle', description: 'Test description' }); expect(res.status).to.equal(201); expect(res.body).to.have.property('cn', 'TestTitle'); }); it('should update a title', async () => { await plugin.instances[0].addEntry('TestTitle', { description: 'Original', }); const res = await request .put( `/api/v1/ldap/titles/cn=TestTitle,ou=twakeTitle,ou=nomenclature,${process.env.DM_LDAP_BASE}` ) .send({ replace: { description: 'Updated' } }); expect(res.status).to.equal(200); const entries = await plugin.instances[0].searchEntriesByName( 'TestTitle', false, ['cn', 'description'] ); expect(entries.TestTitle).to.have.property('description', 'Updated'); }); it('should delete a title', async () => { await plugin.instances[0].addEntry('TestTitle'); const res = await request.delete( `/api/v1/ldap/titles/cn=TestTitle,ou=twakeTitle,ou=nomenclature,${process.env.DM_LDAP_BASE}` ); expect(res.status).to.equal(200); const entries = await plugin.instances[0].listEntries({}); expect(entries).to.not.have.property('TestTitle'); }); }); }); linagora-ldap-rest-16e557e/test/plugins/ldap/flatGenericStandardUsers.test.ts000066400000000000000000000070731522642357000274130ustar00rootroot00000000000000import { expect } from 'chai'; import LdapFlatGeneric from '../../../src/plugins/ldap/flatGeneric'; import { DM } from '../../../src/bin'; const { DM_LDAP_BASE } = process.env; const USER_BRANCH = `ou=users,${DM_LDAP_BASE}`; describe('LdapUsersFlat validation with standard schema (via flatGeneric)', function () { // Skip all tests if required env vars are not set if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_BASE ) { // eslint-disable-next-line no-console console.warn( 'Skipping ldapUsersFlat standard schema validation tests: DM_LDAP_BASE and LDAP credentials are required' ); // @ts-ignore this.skip?.(); return; } let server: DM; let genericPlugin: LdapFlatGeneric; let plugin: any; before(async function () { this.timeout(5000); process.env.DM_LDAP_FLAT_SCHEMA = './static/schemas/standard/users.json'; server = new DM(); genericPlugin = new LdapFlatGeneric(server); plugin = genericPlugin.instances[0]; // Add backward compatibility aliases plugin.addUser = plugin.addEntry.bind(plugin); plugin.deleteUser = plugin.deleteEntry.bind(plugin); plugin.searchUsersByName = plugin.searchEntriesByName.bind(plugin); plugin.listUsers = plugin.listEntries.bind(plugin); }); afterEach(async () => { try { await plugin.deleteUser('testuser2').catch(() => {}); await plugin.deleteUser('testuser3').catch(() => {}); } catch (e) { console.error('After error', e); } }); describe('constructor', () => { it('should set base from config', () => { expect(plugin.base).to.equal(USER_BRANCH); }); }); describe('New user with standard schema validation', () => { it('should add/delete user with required fields', async () => { await plugin.addUser('testuser2', { cn: 'Test User 2', sn: 'User', mail: 'testuser2-schema@example.org', }); const listEntries = await plugin.listUsers({}); // @ts-ignore expect(listEntries).to.have.property('testuser2'); expect(await plugin.searchUsersByName('testuser2')).to.deep.equal({ testuser2: { dn: `uid=testuser2,${USER_BRANCH}`, uid: 'testuser2', }, }); expect(await plugin.deleteUser('testuser2')).to.be.true; }); it('should reject user with invalid uid format', async () => { try { await plugin.addUser('test user!', { cn: 'Test User', sn: 'User', mail: 'testuser-invalid-uid@example.org', }); expect.fail('Should reject invalid uid format'); } catch (e) { expect((e as Error).message).to.match( /Invalid value for attribute "uid"/ ); } }); it('should reject user with missing required fields', async () => { try { await plugin.addUser('testuser3', { cn: 'Test User', // Missing sn mail: 'testuser3-missing-sn@example.org', }); expect.fail('Should reject missing required field'); } catch (e) { expect((e as Error).message).to.match(/Attribute "sn" is required/); } }); it('should reject user with invalid email format', async () => { try { await plugin.addUser('testuser3', { cn: 'Test User', sn: 'User', mail: 'invalid-email-schema', }); expect.fail('Should reject invalid email format'); } catch (e) { expect((e as Error).message).to.match( /Invalid value for attribute "mail"/ ); } }); }); }); linagora-ldap-rest-16e557e/test/plugins/ldap/flatGenericTwakeUsers.test.ts000066400000000000000000000467231522642357000267330ustar00rootroot00000000000000import { expect } from 'chai'; import LdapFlatGeneric from '../../../src/plugins/ldap/flatGeneric'; import { DM } from '../../../src/bin'; import supertest from 'supertest'; const { DM_LDAP_BASE } = process.env; const USER_BRANCH = `ou=users,${DM_LDAP_BASE}`; const twakeAttr = { twakeDepartmentPath: 'Test / SubTest', twakeDepartmentLink: `ou=Test,${DM_LDAP_BASE}`, twakeAccountStatus: `cn=active,ou=twakeAccountStatus,ou=nomenclature,${DM_LDAP_BASE}`, twakeDeliveryMode: `cn=normal,ou=twakeDeliveryMode,ou=nomenclature,${DM_LDAP_BASE}`, }; describe('LdapUsersFlat Plugin (via flatGeneric)', function () { // Skip all tests if required env vars are not set if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_BASE ) { // eslint-disable-next-line no-console console.warn( 'Skipping ldapUsersFlat tests: DM_LDAP_BASE and LDAP credentials are required' ); // @ts-ignore this.skip?.(); return; } let server: DM; let genericPlugin: LdapFlatGeneric; let plugin: any; // The users instance from flatGeneric before(async () => { process.env.DM_LDAP_FLAT_SCHEMA = './static/schemas/twake/users.json'; server = new DM(); genericPlugin = new LdapFlatGeneric(server); plugin = genericPlugin.instances[0]; // Add backward compatibility aliases plugin.addUser = plugin.addEntry.bind(plugin); plugin.deleteUser = plugin.deleteEntry.bind(plugin); plugin.modifyUser = plugin.modifyEntry.bind(plugin); plugin.renameUser = plugin.renameEntry.bind(plugin); plugin.listUsers = plugin.listEntries.bind(plugin); plugin.searchUsersByName = plugin.searchEntriesByName.bind(plugin); }); afterEach(async () => { try { await plugin.deleteUser('testuser'); } catch (e) { // ignore } }); describe('constructor', () => { it('should set base from config', () => { expect(plugin.base).to.equal(USER_BRANCH); }); }); describe('New user', () => { it('should add/delete user', async () => { await plugin.addUser('testuser', { cn: 'Test User', sn: 'User', mail: 'testuser-flat@example.org', ...twakeAttr, }); const listEntries = await plugin.listUsers({}); // @ts-ignore expect(listEntries).to.have.property('testuser'); expect(await plugin.searchUsersByName('testuser')).to.deep.equal({ testuser: { dn: `uid=testuser,${USER_BRANCH}`, uid: 'testuser', }, }); expect(await plugin.deleteUser('testuser')).to.be.true; }); it('should add/delete user with additional attributes', async () => { await plugin.addUser('testuser', { cn: 'Test User', sn: 'User', mail: 'testuser-flat-2@example.org', givenName: 'Test', displayName: 'Test User', ...twakeAttr, }); expect( await plugin.searchUsersByName('testuser', false, [ 'uid', 'cn', 'sn', 'mail', 'givenName', 'displayName', ]) ).to.deep.equal({ testuser: { dn: `uid=testuser,${USER_BRANCH}`, uid: 'testuser', cn: 'Test User', sn: 'User', mail: 'testuser-flat-2@example.org', givenName: 'Test', displayName: 'Test User', }, }); expect(await plugin.deleteUser('testuser')).to.be.true; }); it('should add/modify/delete user with additional attributes', async () => { await plugin.addUser('testuser', { cn: 'Test User', sn: 'User', mail: 'testuser-flat-3@example.org', displayName: 'Test User', ...twakeAttr, }); expect( await plugin.searchUsersByName('testuser', false, [ 'uid', 'displayName', ]) ).to.deep.equal({ testuser: { dn: `uid=testuser,${USER_BRANCH}`, uid: 'testuser', displayName: 'Test User', }, }); await plugin.modifyUser('testuser', { replace: { displayName: 'Modified Test User' }, }); expect( await plugin.searchUsersByName('testuser', false, [ 'uid', 'displayName', ]) ).to.deep.equal({ testuser: { dn: `uid=testuser,${USER_BRANCH}`, uid: 'testuser', displayName: 'Modified Test User', }, }); expect(await plugin.deleteUser('testuser')).to.be.true; }); }); describe('Rename user', () => { this.afterEach(async () => { try { await plugin.deleteUser('testuserbis'); } catch (e) { // ignore } }); it('should rename user', async () => { await plugin.addUser('testuser', { cn: 'Test User', sn: 'User', mail: 'testuser-flat-rename@example.org', ...twakeAttr, }); expect(await plugin.searchUsersByName('testuser')).to.deep.equal({ testuser: { dn: `uid=testuser,${USER_BRANCH}`, uid: 'testuser', }, }); await plugin.renameUser('testuser', 'testuserbis'); expect(await plugin.searchUsersByName('testuser')).to.deep.equal({}); expect(await plugin.searchUsersByName('testuserbis')).to.deep.equal({ testuserbis: { dn: `uid=testuserbis,${USER_BRANCH}`, uid: 'testuserbis', }, }); }); }); describe('API', () => { let request: any; before(async () => { plugin.api(server.app); server.setupErrorMiddleware(); request = supertest(server.app); }); it('should add/del user via API', async () => { let res = await request .post('/api/v1/ldap/users') .type('json') .send({ uid: 'testuser', cn: 'Test User', sn: 'User', mail: 'testuser-flat-api@example.org', ...twakeAttr, }); expect(res.status).to.equal(201); expect(res.body).to.have.property('uid', 'testuser'); expect(await plugin.searchUsersByName('testuser')).to.deep.equal({ testuser: { dn: `uid=testuser,${USER_BRANCH}`, uid: 'testuser', }, }); res = await request .delete('/api/v1/ldap/users/testuser') .type('json') .send(); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ success: true }); expect(await plugin.searchUsersByName('testuser')).to.deep.equal({}); }); it('should modify user via API', async () => { await plugin.addUser('testuser', { cn: 'Test User', sn: 'User', mail: 'testuser-flat-api-modify@example.org', displayName: 'Test User', ...twakeAttr, }); let res = await request .put('/api/v1/ldap/users/testuser') .type('json') .send({ replace: { displayName: 'Modified via API' }, }); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ success: true }); expect( await plugin.searchUsersByName('testuser', false, [ 'uid', 'displayName', ]) ).to.deep.equal({ testuser: { dn: `uid=testuser,${USER_BRANCH}`, uid: 'testuser', displayName: 'Modified via API', }, }); }); it('should list via API', async () => { let res = await request .post('/api/v1/ldap/users') .type('json') .send({ uid: 'testuser', cn: 'Test User', sn: 'User', mail: 'testuser-flat-api-list@example.org', ...twakeAttr, }); expect(res.status).to.equal(201); expect(res.body).to.have.property('uid', 'testuser'); res = await request .get('/api/v1/ldap/users?match=uid=*estuse*&attributes=uid,mail') .set('Accept', 'application/json'); expect(res.status).to.equal(200); expect(res.body).to.have.property('testuser'); expect(res.body.testuser).to.deep.equal({ dn: `uid=testuser,${USER_BRANCH}`, uid: 'testuser', mail: 'testuser-flat-api-list@example.org', }); }); }); describe('Move user between departments', () => { const testOrgDn = `ou=TestOrg,${DM_LDAP_BASE}`; const testOrg2Dn = `ou=TestOrg2,${DM_LDAP_BASE}`; beforeEach(async () => { // Create test organizations try { await server.ldap.add(testOrgDn, { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'Test / Org', }); } catch (err) { // Ignore if already exists } try { await server.ldap.add(testOrg2Dn, { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg2', twakeDepartmentPath: 'Test / Org2', }); } catch (err) { // Ignore if already exists } }); afterEach(async () => { // Clean up test organizations try { await server.ldap.delete(testOrgDn); } catch (err) { // Ignore if doesn't exist } try { await server.ldap.delete(testOrg2Dn); } catch (err) { // Ignore if doesn't exist } }); it('should move user to different organization', async () => { // Create user in first organization await plugin.addUser('testuser', { cn: 'Test User', sn: 'User', mail: 'testuser-move@example.org', twakeDepartmentPath: 'Test / Org', twakeDepartmentLink: testOrgDn, twakeAccountStatus: `cn=active,ou=twakeAccountStatus,ou=nomenclature,${DM_LDAP_BASE}`, twakeDeliveryMode: `cn=normal,ou=twakeDeliveryMode,ou=nomenclature,${DM_LDAP_BASE}`, }); // Move user to second organization const result = await plugin.moveEntry('testuser', testOrg2Dn); // Verify result expect(result).to.have.property('departmentPath', 'Test / Org2'); expect(result).to.have.property('departmentLink', testOrg2Dn); // Verify user was updated in LDAP const userEntry = await plugin.searchUsersByName('testuser', false, [ 'uid', 'twakeDepartmentPath', 'twakeDepartmentLink', ]); expect(userEntry.testuser).to.have.property( 'twakeDepartmentPath', 'Test / Org2' ); expect(userEntry.testuser).to.have.property( 'twakeDepartmentLink', testOrg2Dn ); }); it('should move user using DN as identifier', async () => { // Create user in first organization await plugin.addUser('testuser', { cn: 'Test User', sn: 'User', mail: 'testuser-move-dn@example.org', twakeDepartmentPath: 'Test / Org', twakeDepartmentLink: testOrgDn, twakeAccountStatus: `cn=active,ou=twakeAccountStatus,ou=nomenclature,${DM_LDAP_BASE}`, twakeDeliveryMode: `cn=normal,ou=twakeDeliveryMode,ou=nomenclature,${DM_LDAP_BASE}`, }); const userDn = `uid=testuser,${USER_BRANCH}`; // Move user using full DN const result = await plugin.moveEntry(userDn, testOrg2Dn); // Verify result expect(result).to.have.property('departmentPath', 'Test / Org2'); expect(result).to.have.property('departmentLink', testOrg2Dn); }); it('should fail when moving to non-existent organization', async () => { // Create user await plugin.addUser('testuser', { cn: 'Test User', sn: 'User', mail: 'testuser-move-fail@example.org', twakeDepartmentPath: 'Test / Org', twakeDepartmentLink: testOrgDn, twakeAccountStatus: `cn=active,ou=twakeAccountStatus,ou=nomenclature,${DM_LDAP_BASE}`, twakeDeliveryMode: `cn=normal,ou=twakeDeliveryMode,ou=nomenclature,${DM_LDAP_BASE}`, }); // Try to move to non-existent org try { await plugin.moveEntry('testuser', `ou=NonExistent,${DM_LDAP_BASE}`); expect.fail('Should have thrown an error'); } catch (err: any) { expect(err.message).to.match( /Organization.*not found|Failed to fetch organization/ ); } }); it('should fail when moving non-existent user', async () => { try { await plugin.moveEntry('nonexistent', testOrg2Dn); expect.fail('Should have thrown an error'); } catch (err: any) { expect(err.message).to.match(/Entry.*not found|Code: 0x20/); } }); it.skip('should call move hook before moving', async () => { let moveHookCalled = false; let capturedTargetOrg = ''; // Create test plugin to register hooks const hookPlugin = { name: 'test-move-hooks', hooks: { ldapusermove: async ([dn, targetOrgDn]: [string, string]) => { moveHookCalled = true; capturedTargetOrg = targetOrgDn; return [dn, targetOrgDn]; }, }, }; // Register hooks via plugin await server.registerPlugin('test-move-hooks', hookPlugin as any); // Create user await plugin.addUser('testuser', { cn: 'Test User', sn: 'User', mail: 'testuser-move-hooks@example.org', twakeDepartmentPath: 'Test / Org', twakeDepartmentLink: testOrgDn, twakeAccountStatus: `cn=active,ou=twakeAccountStatus,ou=nomenclature,${DM_LDAP_BASE}`, twakeDeliveryMode: `cn=normal,ou=twakeDeliveryMode,ou=nomenclature,${DM_LDAP_BASE}`, }); // Move user await plugin.moveEntry('testuser', testOrg2Dn); // Verify hook was called expect(moveHookCalled).to.be.true; expect(capturedTargetOrg).to.equal(testOrg2Dn); }); }); describe('Move API endpoint', () => { let request: any; const testOrgDn = `ou=TestOrg,${DM_LDAP_BASE}`; const testOrg2Dn = `ou=TestOrg2,${DM_LDAP_BASE}`; before(async () => { plugin.api(server.app); request = supertest(server.app); // Create test organizations try { await server.ldap.add(testOrgDn, { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg', twakeDepartmentPath: 'Test / Org', }); } catch (err) { // Ignore if already exists } try { await server.ldap.add(testOrg2Dn, { objectClass: ['top', 'organizationalUnit', 'twakeDepartment'], ou: 'TestOrg2', twakeDepartmentPath: 'Test / Org2', }); } catch (err) { // Ignore if already exists } }); after(async () => { // Clean up test organizations try { await server.ldap.delete(testOrgDn); } catch (err) { // Ignore } try { await server.ldap.delete(testOrg2Dn); } catch (err) { // Ignore } }); it('should move user via API', async () => { // Create user await plugin.addUser('testuser', { cn: 'Test User', sn: 'User', mail: 'testuser-move-api@example.org', twakeDepartmentPath: 'Test / Org', twakeDepartmentLink: testOrgDn, twakeAccountStatus: `cn=active,ou=twakeAccountStatus,ou=nomenclature,${DM_LDAP_BASE}`, twakeDeliveryMode: `cn=normal,ou=twakeDeliveryMode,ou=nomenclature,${DM_LDAP_BASE}`, }); // Move user via API const res = await request .post('/api/v1/ldap/users/testuser/move') .type('json') .send({ targetOrgDn: testOrg2Dn }); expect(res.status).to.equal(200); expect(res.body).to.have.property('success', true); expect(res.body).to.have.property('departmentPath', 'Test / Org2'); expect(res.body).to.have.property('departmentLink', testOrg2Dn); // Verify user was updated const userEntry = await plugin.searchUsersByName('testuser', false, [ 'uid', 'twakeDepartmentPath', 'twakeDepartmentLink', ]); expect(userEntry.testuser).to.have.property( 'twakeDepartmentPath', 'Test / Org2' ); expect(userEntry.testuser).to.have.property( 'twakeDepartmentLink', testOrg2Dn ); }); it('should return 400 when targetOrgDn is missing', async () => { const res = await request .post('/api/v1/ldap/users/testuser/move') .type('json') .send({}); expect(res.status).to.equal(400); expect(res.body).to.have.property('error'); expect(res.body.error).to.match(/targetOrgDn|Bad content/); }); it('should return 500 when user does not exist', async () => { const res = await request .post('/api/v1/ldap/users/nonexistent/move') .type('json') .send({ targetOrgDn: testOrg2Dn }); expect(res.status).to.equal(500); expect(res.body).to.have.property('error'); }); }); describe('Pointer type validation', () => { it('should reject user with non-existent pointer DN', async () => { try { await plugin.addUser('testuser', { cn: 'Test User', sn: 'User', mail: 'testuser-pointer@example.org', twakeDepartmentPath: 'Test / SubTest', twakeDepartmentLink: `ou=Test,${DM_LDAP_BASE}`, twakeAccountStatus: `cn=nonexistent,ou=twakeAccountStatus,ou=nomenclature,${DM_LDAP_BASE}`, twakeDeliveryMode: `cn=normal,ou=twakeDeliveryMode,ou=nomenclature,${DM_LDAP_BASE}`, }); expect.fail('Should have thrown an error'); } catch (err: any) { expect(err.message).to.include('points to invalid or non-existent DN'); } }); it('should reject user with pointer DN outside allowed branch', async () => { try { await plugin.addUser('testuser', { cn: 'Test User', sn: 'User', mail: 'testuser-pointer@example.org', twakeDepartmentPath: 'Test / SubTest', twakeDepartmentLink: `ou=Test,${DM_LDAP_BASE}`, // Using a DN from wrong branch twakeAccountStatus: `cn=normal,ou=twakeDeliveryMode,ou=nomenclature,${DM_LDAP_BASE}`, twakeDeliveryMode: `cn=normal,ou=twakeDeliveryMode,ou=nomenclature,${DM_LDAP_BASE}`, }); expect.fail('Should have thrown an error'); } catch (err: any) { expect(err.message).to.include( 'must point to a DN within allowed branches' ); } }); it('should accept user with valid pointer DNs', async () => { await plugin.addUser('testuser', { cn: 'Test User', sn: 'User', mail: 'testuser-pointer-valid@example.org', twakeDepartmentPath: 'Test / SubTest', twakeDepartmentLink: `ou=Test,${DM_LDAP_BASE}`, twakeAccountStatus: `cn=active,ou=twakeAccountStatus,ou=nomenclature,${DM_LDAP_BASE}`, twakeDeliveryMode: `cn=normal,ou=twakeDeliveryMode,ou=nomenclature,${DM_LDAP_BASE}`, }); const users = await plugin.searchUsersByName('testuser'); expect(users).to.have.property('testuser'); await plugin.deleteUser('testuser'); }); it('should reject modification with invalid pointer DN', async () => { await plugin.addUser('testuser', { cn: 'Test User', sn: 'User', mail: 'testuser-pointer-modify@example.org', ...twakeAttr, }); try { await plugin.modifyUser('testuser', { replace: { twakeAccountStatus: `cn=invalid,ou=twakeAccountStatus,ou=nomenclature,${DM_LDAP_BASE}`, }, }); expect.fail('Should have thrown an error'); } catch (err: any) { expect(err.message).to.include('points to invalid or non-existent DN'); } finally { await plugin.deleteUser('testuser'); } }); }); }); linagora-ldap-rest-16e557e/test/plugins/ldap/getApis.test.ts000066400000000000000000000130011522642357000240450ustar00rootroot00000000000000import { expect } from 'chai'; import supertest from 'supertest'; import { DM } from '../../../src/bin'; import LdapFlatGeneric from '../../../src/plugins/ldap/flatGeneric'; import LdapGroups from '../../../src/plugins/ldap/groups'; import LdapOrganizations from '../../../src/plugins/ldap/organizations'; describe('GET APIs for individual entities', function () { let server: DM; let flatPlugin: LdapFlatGeneric; let groupsPlugin: LdapGroups; let orgPlugin: LdapOrganizations; let request: any; before(async function () { // Skip all tests if required env vars are not set if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_GROUP_BASE ) { // eslint-disable-next-line no-console console.warn( 'Skipping GET API tests: LDAP credentials and DM_LDAP_GROUP_BASE are required' ); this.skip(); return; } this.timeout(5000); process.env.DM_LDAP_FLAT_SCHEMA = './static/schemas/twake/nomenclature/twakeTitle.json'; server = new DM(); await server.ready; flatPlugin = new LdapFlatGeneric(server); await server.registerPlugin('ldapFlatGeneric', flatPlugin); groupsPlugin = new LdapGroups(server); await server.registerPlugin('ldapGroups', groupsPlugin); if (process.env.DM_LDAP_TOP_ORGANIZATION) { orgPlugin = new LdapOrganizations(server); await server.registerPlugin('ldapOrganizations', orgPlugin); } request = supertest(server.app); }); describe('Flat entities GET', () => { it('should get title by simple id', async () => { const res = await request.get('/api/v1/ldap/titles/Dr'); expect(res.status).to.equal(200); expect(res.body).to.have.property('cn', 'Dr'); expect(res.body).to.have.property('dn'); }); it('should get title by full DN', async () => { const dn = `cn=Dr,ou=twakeTitle,ou=nomenclature,${process.env.DM_LDAP_BASE}`; const encodedDn = encodeURIComponent(dn); const res = await request.get(`/api/v1/ldap/titles/${encodedDn}`); expect(res.status).to.equal(200); expect(res.body).to.have.property('cn', 'Dr'); expect(res.body).to.have.property('dn', dn); }); it('should return 404 for non-existent title', async () => { const res = await request.get('/api/v1/ldap/titles/NonExistentTitle'); expect(res.status).to.equal(404); expect(res.body).to.have.property('error'); }); }); describe('Groups GET', () => { let testGroupCn: string; let testGroupDn: string; before(async function () { this.timeout(5000); // Find an existing group to use for testing const groups = await groupsPlugin.listGroups(); const groupNames = Object.keys(groups).filter( name => name && name.length > 0 ); if (groupNames.length === 0) { throw new Error('No groups found in LDAP for testing'); } testGroupCn = groupNames[0]; testGroupDn = groups[testGroupCn].dn as string; }); it('should get group by simple cn', async () => { const res = await request.get(`/api/v1/ldap/groups/${testGroupCn}`); expect(res.status).to.equal(200); expect(res.body).to.have.property('dn'); // Check that the DN contains the cn we looked for expect((res.body.dn as string).toLowerCase()).to.include( testGroupCn.toLowerCase() ); }); it('should get group by full DN', async () => { const encodedDn = encodeURIComponent(testGroupDn); const res = await request.get(`/api/v1/ldap/groups/${encodedDn}`); expect(res.status).to.equal(200); expect(res.body).to.have.property('dn', testGroupDn); }); it('should return 404 for non-existent group', async () => { const res = await request.get('/api/v1/ldap/groups/nonexistent-group'); expect(res.status).to.equal(404); expect(res.body).to.have.property('error'); }); }); describe('Organizations GET and subnodes', function () { if (!process.env.DM_LDAP_TOP_ORGANIZATION) { // eslint-disable-next-line no-console console.warn( 'Skipping organizations tests: DM_LDAP_TOP_ORGANIZATION not set' ); // @ts-ignore this.skip?.(); return; } it('should get top organization', async () => { const res = await request.get('/api/v1/ldap/organizations/top'); expect(res.status).to.equal(200); expect(res.body).to.have.property('dn'); }); it('should get organization by DN', async () => { const topOrg = process.env.DM_LDAP_TOP_ORGANIZATION as string; const encodedDn = encodeURIComponent(topOrg); const res = await request.get(`/api/v1/ldap/organizations/${encodedDn}`); expect(res.status).to.equal(200); expect(res.body).to.have.property('dn'); }); it('should get organization subnodes', async () => { const topOrg = process.env.DM_LDAP_TOP_ORGANIZATION as string; const encodedDn = encodeURIComponent(topOrg); const res = await request.get( `/api/v1/ldap/organizations/${encodedDn}/subnodes` ); expect(res.status).to.equal(200); expect(res.body).to.be.an('array'); // Should contain users, groups, or sub-organizations with link attribute }); it('should return error for non-existent organization', async () => { const fakeDn = 'ou=nonexistent,dc=example,dc=com'; const encodedDn = encodeURIComponent(fakeDn); const res = await request.get(`/api/v1/ldap/organizations/${encodedDn}`); expect(res.status).to.equal(500); }); }); }); linagora-ldap-rest-16e557e/test/plugins/ldap/ldapFlatBaseScope.test.ts000066400000000000000000000241531522642357000257770ustar00rootroot00000000000000/** * Regression tests for the base-scope guard on LdapFlat operations. * * Context: every CRUD method of LdapFlat accepts an `id` that can be either * an RDN value or a full DN. Before the fix, only addEntry enforced that a * full DN had to terminate with `,this.base`. Callers scoped to one resource * (e.g. /ldap/titles) could pass a DN pointing at another branch (e.g. * ou=groups,...) and read/modify/delete entries outside the resource's scope. */ import { expect } from 'chai'; import supertest from 'supertest'; import LdapFlatGeneric from '../../../src/plugins/ldap/flatGeneric'; import { DM } from '../../../src/bin'; import { BadRequestError } from '../../../src/lib/errors'; import { skipIfMissingEnvVars, LDAP_ENV_VARS } from '../../helpers/env'; describe('LdapFlat base-scope guard', function () { before(function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS]); }); let server: DM; let plugin: LdapFlatGeneric; let instance: LdapFlatGeneric['instances'][number]; let request: any; let BASE: string; let TITLE_BRANCH: string; let FOREIGN_DN: string; before(async function () { this.timeout(5000); process.env.DM_LDAP_FLAT_SCHEMA = './static/schemas/twake/nomenclature/twakeTitle.json'; server = new DM(); await server.ready; plugin = new LdapFlatGeneric(server); await server.registerPlugin('ldapFlatBaseScope', plugin); request = supertest(server.app); instance = plugin.instances[0]; BASE = process.env.DM_LDAP_BASE as string; TITLE_BRANCH = `ou=twakeTitle,ou=nomenclature,${BASE}`; // A DN that sits OUTSIDE the title branch — representative of the bypass. // We use the same RDN attribute (cn) as the title branch so the guard's // DN-detection triggers; different-attribute DNs are handled by the escape // path and tested separately below. FOREIGN_DN = `cn=hijacked,ou=users,${BASE}`; }); /** * Encode the DN the way clients send it in the URL path. */ const enc = (dn: string) => encodeURIComponent(dn); describe('direct method calls', () => { it('addEntry rejects a DN outside the branch', async () => { try { await instance.addEntry(FOREIGN_DN); throw new Error('expected BadRequestError'); } catch (err) { expect(err).to.be.instanceOf(BadRequestError); expect((err as Error).message).to.match(/must be a direct child of/i); } }); it('modifyEntry rejects a DN outside the branch', async () => { try { await instance.modifyEntry(FOREIGN_DN, { replace: { description: 'hijacked' }, }); throw new Error('expected BadRequestError'); } catch (err) { expect(err).to.be.instanceOf(BadRequestError); } }); it('deleteEntry rejects a DN outside the branch', async () => { try { await instance.deleteEntry(FOREIGN_DN); throw new Error('expected BadRequestError'); } catch (err) { expect(err).to.be.instanceOf(BadRequestError); } }); it('renameEntry rejects a source DN outside the branch', async () => { try { await instance.renameEntry(FOREIGN_DN, 'NewTitleName'); throw new Error('expected BadRequestError'); } catch (err) { expect(err).to.be.instanceOf(BadRequestError); } }); it('renameEntry rejects a target DN outside the branch', async () => { // Source is a valid in-branch DN so the rejection comes from the target. const validSource = `cn=some-existing,${TITLE_BRANCH}`; try { await instance.renameEntry(validSource, FOREIGN_DN); throw new Error('expected BadRequestError'); } catch (err) { expect(err).to.be.instanceOf(BadRequestError); } }); it('moveEntry rejects a DN outside the branch', async () => { try { await instance.moveEntry(FOREIGN_DN, TITLE_BRANCH); throw new Error('expected BadRequestError'); } catch (err) { expect(err).to.be.instanceOf(BadRequestError); } }); }); describe('HTTP API', () => { it('GET rejects a DN outside the branch', async () => { const res = await request.get(`/api/v1/ldap/titles/${enc(FOREIGN_DN)}`); expect(res.status).to.equal(400); }); it('PUT rejects a DN outside the branch', async () => { const res = await request .put(`/api/v1/ldap/titles/${enc(FOREIGN_DN)}`) .send({ replace: { description: 'hijacked' } }); expect(res.status).to.equal(400); }); it('DELETE rejects a DN outside the branch', async () => { const res = await request.delete( `/api/v1/ldap/titles/${enc(FOREIGN_DN)}` ); expect(res.status).to.equal(400); }); it('POST /move rejects a source DN outside the branch', async () => { const res = await request .post(`/api/v1/ldap/titles/${enc(FOREIGN_DN)}/move`) .send({ targetOrgDn: TITLE_BRANCH }); expect(res.status).to.equal(400); }); it('POST (add) rejects a DN outside the branch in the main attribute', async () => { const res = await request .post('/api/v1/ldap/titles') .send({ cn: `cn=Foo,ou=somethingElse,${BASE}` }); expect(res.status).to.equal(400); }); }); describe('positive path (in-branch DN still accepted)', () => { const uid = 'TestScopedTitle'; const inBranchDn = () => `cn=${uid},${TITLE_BRANCH}`; afterEach(async () => { try { await instance.deleteEntry(uid); } catch (e) { // ignore } }); it('accepts the full DN when it ends with the base (exact case)', async () => { await instance.addEntry(inBranchDn()); const res = await request.get(`/api/v1/ldap/titles/${enc(inBranchDn())}`); expect(res.status).to.equal(200); expect(res.body).to.have.property('cn', uid); }); it('accepts the full DN with mixed-case suffix (LDAP is case-insensitive)', async () => { await instance.addEntry(uid); const mixedCase = `cn=${uid},OU=twakeTitle,ou=Nomenclature,${BASE}`; const res = await request.get(`/api/v1/ldap/titles/${enc(mixedCase)}`); // LDAP will resolve it (case-insensitive compare) — the guard must not block it expect(res.status).to.equal(200); }); it('rejects a partial DN (prefix without base) rather than producing a malformed DN', () => { // Regression caught during review: "cn=foo" starts with the main // attribute but is not a full DN. It must be rejected cleanly by the // guard, not silently re-escaped into `cn=cn\=foo,` with an // attribute value that no longer matches the RDN. expect(() => ( instance as unknown as { resolveDn: (id: string) => string } ).resolveDn('cn=foo') ).to.throw(BadRequestError, /must be a direct child of/i); }); it('does not misclassify a main-attribute value containing a comma as a DN', () => { // Regression caught during review: with a naive comma-based heuristic, // values like "Smith, John" (legitimate for a cn-based branch) would be // treated as DNs and rejected by the suffix check. The guard must only // trigger when the id actually starts with `mainAttribute=`. const valueWithComma = 'Smith, John'; const dn = ( instance as unknown as { resolveDn: (id: string) => string } ).resolveDn(valueWithComma); expect(dn).to.equal(`cn=Smith\\, John,${TITLE_BRANCH}`); }); }); describe('escape-aware DN parsing (guards against RFC 4514 tricks)', () => { const callResolve = (dn: string) => (instance as unknown as { resolveDn: (id: string) => string }).resolveDn( dn ); it('rejects a DN whose textual tail matches the base via an escaped comma in the first RDN', () => { // Attack: `cn=pwn\,ou=twakeTitle,ou=nomenclature,` textually // ends with `,ou=twakeTitle,ou=nomenclature,`, so a naive // `endsWith` check would accept it. But per RFC 4514 `\,` is a // literal comma inside the first RDN value, so the entry's real // parent is `ou=nomenclature,` — a sibling of the titles // branch. Passing this DN through to ldapts would let a titles-API // caller create/read/modify/delete entries outside the titles // branch. const malicious = `cn=pwn\\,ou=twakeTitle,ou=nomenclature,${BASE}`; expect(() => callResolve(malicious)).to.throw( BadRequestError, /must be a direct child of/i ); }); it('rejects a DN whose tail matches via a deeply nested escaped comma', () => { // Same trick, one RDN deeper: the entry's real parent would be // `ou=other,`, still outside the titles branch. const malicious = `cn=pwn\\,deep,ou=other,ou=twakeTitle,ou=nomenclature,${BASE}`; expect(() => callResolve(malicious)).to.throw( BadRequestError, /must be a direct child of/i ); }); it('rejects an HTTP DELETE with an escaped-comma bypass payload', async () => { // End-to-end: the decodeURIComponent'd path parameter must not reach // ldap.delete with a DN whose real parent is outside the branch. const malicious = `cn=pwn\\,ou=twakeTitle,ou=nomenclature,${BASE}`; const res = await request.delete(`/api/v1/ldap/titles/${enc(malicious)}`); expect(res.status).to.equal(400); }); it('rejects an HTTP POST add with an escaped-comma bypass payload', async () => { const malicious = `cn=pwn\\,ou=twakeTitle,ou=nomenclature,${BASE}`; const res = await request .post('/api/v1/ldap/titles') .send({ cn: malicious }); expect(res.status).to.equal(400); }); it('still accepts a legitimate direct child of the base', () => { const dn = `cn=ValidTitle,${TITLE_BRANCH}`; expect(callResolve(dn)).to.equal(dn); }); it('still accepts a direct child with an escaped comma in the RDN value', () => { // `cn=Smith\, John,` is a legitimate in-branch DN with a // literal comma inside the cn value. The parent is the branch, not a // sibling, so the guard must NOT reject it. const dn = `cn=Smith\\, John,${TITLE_BRANCH}`; expect(callResolve(dn)).to.equal(dn); }); }); }); linagora-ldap-rest-16e557e/test/plugins/ldap/ldapGroups.test.ts000066400000000000000000000322621522642357000246030ustar00rootroot00000000000000import { expect } from 'chai'; import LdapGroups from '../../../src/plugins/ldap/groups'; import { DM } from '../../../src/bin'; import supertest from 'supertest'; import { SearchResult } from 'ldapts'; const { DM_LDAP_GROUP_BASE } = process.env; process.env.DM_GROUP_SCHEMA = ''; describe('LdapGroups Plugin', function () { // Skip all tests if required env vars are not set if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_GROUP_BASE ) { // eslint-disable-next-line no-console console.warn( 'Skipping ldapGroups tests: DM_GROUP_BASE and LDAP_LIB env vars are required' ); // @ts-ignore this.skip?.(); return; } let server: DM; let plugin: LdapGroups; const user1 = `uid=user1,${process.env.DM_LDAP_BASE}`; const user2 = `uid=user2,${process.env.DM_LDAP_BASE}`; before(async () => { server = new DM(); plugin = new LdapGroups(server); const entry = { objectClass: ['inetOrgPerson', 'organizationalPerson', 'person', 'top'], cn: 'Test User', sn: 'User', uid: 'testuser', mail: 'test@test.org', }; await plugin.ldap.add(user1, entry); await plugin.ldap.add(user2, { ...entry, mail: 'test2@test.org' }); }); after(async () => { try { await plugin.ldap.delete(user1); await plugin.ldap.delete(user2); } catch (e) { // ignore } }); afterEach(async () => { try { await plugin.deleteGroup('testgroup'); } catch (e) { // ignore } }); describe('constructor', () => { it('should set base from config', () => { expect(plugin.base).to.equal(DM_LDAP_GROUP_BASE); }); }); describe('New group', () => { it('should add/delete group with members', async () => { await plugin.addGroup('testgroup', [user1]); const listEntries = await plugin.listGroups(); // @ts-ignore expect(listEntries).to.have.property('testgroup'); expect(await plugin.searchGroupsByName('testgroup')).to.deep.equal({ testgroup: { dn: `cn=testgroup,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup', member: [user1], }, }); expect(await plugin.deleteGroup('testgroup')).to.be.true; }); it('should add/delete group even if no members', async () => { await plugin.addGroup('testgroup'); expect(await plugin.searchGroupsByName('testgroup')).to.deep.equal({ testgroup: { dn: `cn=testgroup,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup', member: [], }, }); expect(await plugin.deleteGroup('testgroup')).to.be.true; }); it('should add/modify/delete group with additional attributes', async () => { await plugin.addGroup('testgroup', [user1], { description: 'My test group', }); expect( await plugin.searchGroupsByName('testgroup', false, [ 'cn', 'description', 'member', ]) ).to.deep.equal({ testgroup: { dn: `cn=testgroup,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup', member: [user1], description: 'My test group', }, }); await plugin.modifyGroup('testgroup', { replace: { description: 'My modified test group' }, }); expect( await plugin.searchGroupsByName('testgroup', false, [ 'cn', 'description', 'member', ]) ).to.deep.equal({ testgroup: { dn: `cn=testgroup,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup', member: [user1], description: 'My modified test group', }, }); expect(await plugin.deleteGroup('testgroup')).to.be.true; }); }); describe('Manipulate member', () => { this.afterEach(async () => { try { await plugin.deleteGroup('testgroupbis'); } catch (e) { // ignore } }); it('should add/delete member to group', async () => { await plugin.addGroup('testgroup'); await plugin.addMember('testgroup', user2); expect(await plugin.searchGroupsByName('testgroup')).to.deep.equal({ testgroup: { dn: `cn=testgroup,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup', member: [user2], }, }); await plugin.deleteMember('testgroup', user2); expect(await plugin.searchGroupsByName('testgroup')).to.deep.equal({ testgroup: { dn: `cn=testgroup,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup', member: [], }, }); }); it('should not accept unexisting user as member', async () => { await plugin.addGroup('testgroup'); try { await plugin.addMember( 'testgroup', 'uid=unexistinguser,' + process.env.DM_LDAP_BASE ); expect.fail('Should not accept unexisting user as member'); } catch (e) { expect((e as Error).message).to.match(/uid=unexistinguser.* not found/); } expect(await plugin.searchGroupsByName('testgroup')).to.deep.equal({ testgroup: { dn: `cn=testgroup,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup', member: [], }, }); }); it('should rename group', async () => { await plugin.addGroup('testgroup', [user1]); expect(await plugin.searchGroupsByName('testgroup')).to.deep.equal({ testgroup: { dn: `cn=testgroup,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup', member: [user1], }, }); await plugin.renameGroup('testgroup', 'testgroupbis'); expect(await plugin.searchGroupsByName('testgroup')).to.deep.equal({}); expect(await plugin.searchGroupsByName('testgroupbis')).to.deep.equal({ testgroupbis: { dn: `cn=testgroupbis,${DM_LDAP_GROUP_BASE}`, cn: 'testgroupbis', member: [user1], }, }); }); }); describe('API', () => { let request: any; before(async () => { plugin.api(server.app); request = supertest(server.app); }); it('should add/del group via API', async () => { let res = await request .post('/api/v1/ldap/groups') .type('json') .send({ cn: 'testgroup', member: [user1], }); expect(res.body).to.deep.equal({ success: true }); expect(res.status).to.equal(200); expect(await plugin.searchGroupsByName('testgroup')).to.deep.equal({ testgroup: { dn: `cn=testgroup,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup', member: [user1], }, }); res = await request .delete('/api/v1/ldap/groups/testgroup') .type('json') .send(); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ success: true }); expect(await plugin.searchGroupsByName('testgroup')).to.deep.equal({}); }); it('should add/del member via API', async () => { await plugin.addGroup('testgroup'); let res = await request .post('/api/v1/ldap/groups/testgroup/members') .type('json') .send({ member: user2, }); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ success: true }); expect(await plugin.searchGroupsByName('testgroup')).to.deep.equal({ testgroup: { dn: `cn=testgroup,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup', member: [user2], }, }); res = await request .delete(`/api/v1/ldap/groups/testgroup/members/${user2}`) .type('json') .send(); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ success: true }); expect(await plugin.searchGroupsByName('testgroup')).to.deep.equal({ testgroup: { dn: `cn=testgroup,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup', member: [], }, }); }); it('should list via API', async () => { let res = await request .post('/api/v1/ldap/groups') .type('json') .send({ cn: 'testgroup', member: [user1], }); expect(res.body).to.deep.equal({ success: true }); expect(res.status).to.equal(200); res = await request .get('/api/v1/ldap/groups?match=cn=*estgrou*&attributes=cn,member') .set('Accept', 'application/json'); expect(res.status).to.equal(200); expect(res.body).to.have.property('testgroup'); expect(res.body.testgroup).to.deep.equal({ dn: `cn=testgroup,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup', member: [user1], }); }); }); describe('moveGroup', function () { // Skip tests if no organization plugin configured if (!process.env.DM_LDAP_TOP_ORGANIZATION) { // eslint-disable-next-line no-console console.warn( 'Skipping moveGroup tests: DM_LDAP_TOP_ORGANIZATION env var is required' ); // @ts-ignore this.skip?.(); return; } const org1Dn = `ou=testorg1,${process.env.DM_LDAP_TOP_ORGANIZATION}`; const org2Dn = `ou=testorg2,${process.env.DM_LDAP_TOP_ORGANIZATION}`; const groupDn = `cn=testgroup,${DM_LDAP_GROUP_BASE}`; beforeEach(async () => { // Create test organizations await plugin.ldap.add(org1Dn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'testorg1', twakeDepartmentPath: 'Test Org 1', }); await plugin.ldap.add(org2Dn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'testorg2', twakeDepartmentPath: 'Test Org 2', }); // Create test group with department link await plugin.ldap.add(groupDn, { objectClass: ['groupOfNames', 'twakeStaticGroup', 'top'], cn: 'testgroup', member: [`cn=fakeuser`], twakeDepartmentLink: org1Dn, twakeDepartmentPath: 'Test Org 1', }); }); afterEach(async () => { try { await plugin.ldap.delete(groupDn); } catch (e) { // ignore } try { await plugin.ldap.delete(org1Dn); } catch (e) { // ignore } try { await plugin.ldap.delete(org2Dn); } catch (e) { // ignore } }); it('should move group to different organization', async () => { const result = await plugin.moveGroup('testgroup', org2Dn); expect(result).to.have.property('success', true); // Verify group was moved const group = (await plugin.ldap.search( { paged: false, scope: 'base' }, groupDn )) as SearchResult; expect(group.searchEntries[0].twakeDepartmentLink).to.equal(org2Dn); expect(group.searchEntries[0].twakeDepartmentPath).to.equal('Test Org 2'); }); it('should reject move to same location', async () => { try { await plugin.moveGroup('testgroup', org1Dn); expect.fail('Should have thrown error'); } catch (e) { expect((e as Error).message).to.match( /already in the target organization/ ); } }); it('should reject move of group without department link', async () => { // Create group without department link const noDeptGroupDn = `cn=nodeptgroup,${DM_LDAP_GROUP_BASE}`; await plugin.ldap.add(noDeptGroupDn, { objectClass: ['groupOfNames', 'top'], cn: 'nodeptgroup', member: [`cn=fakeuser`], }); try { await plugin.moveGroup('nodeptgroup', org2Dn); expect.fail('Should have thrown error'); } catch (e) { expect((e as Error).message).to.match( /does not have twakeDepartmentLink attribute/ ); } finally { await plugin.ldap.delete(noDeptGroupDn); } }); it('should reject move to non-existent organization', async () => { const fakeOrgDn = `ou=nonexistent,${process.env.DM_LDAP_TOP_ORGANIZATION}`; try { await plugin.moveGroup('testgroup', fakeOrgDn); expect.fail('Should have thrown error'); } catch (e) { expect((e as Error).message).to.match(/not found/); } }); it('should move group via API', async () => { const request = supertest(server.app); plugin.api(server.app); const res = await request .post('/api/v1/ldap/groups/testgroup/move') .type('json') .send({ targetOrgDn: org2Dn, }); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ success: true }); // Verify group was moved const group = (await plugin.ldap.search( { paged: false, scope: 'base' }, groupDn )) as SearchResult; expect(group.searchEntries[0].twakeDepartmentLink).to.equal(org2Dn); expect(group.searchEntries[0].twakeDepartmentPath).to.equal('Test Org 2'); }); it('should work with DN parameter', async () => { const result = await plugin.moveGroup(groupDn, org2Dn); expect(result).to.have.property('success', true); // Verify group was moved const group = (await plugin.ldap.search( { paged: false, scope: 'base' }, groupDn )) as SearchResult; expect(group.searchEntries[0].twakeDepartmentLink).to.equal(org2Dn); expect(group.searchEntries[0].twakeDepartmentPath).to.equal('Test Org 2'); }); }); }); linagora-ldap-rest-16e557e/test/plugins/ldap/ldapGroupsSchema.test.ts000066400000000000000000000052541522642357000257250ustar00rootroot00000000000000import { expect } from 'chai'; import LdapGroups from '../../../src/plugins/ldap/groups'; import { DM } from '../../../src/bin'; import supertest from 'supertest'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; const { DM_LDAP_GROUP_BASE } = process.env; const twakeAttr = { twakeDepartmentPath: 'Test / SubTest', twakeDepartmentLink: `ou=Test,${process.env.DM_LDAP_GROUP_BASE}`, }; describe('LdapGroups validation', function () { // Skip all tests if required env vars are not set if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_GROUP_BASE ) { // eslint-disable-next-line no-console console.warn( 'Skipping ldapGroups tests: DM_GROUP_BASE and LDAP_LIB env vars are required' ); // @ts-ignore this.skip?.(); return; } let server: DM; let plugin: LdapGroups; const user1 = `uid=user1,${process.env.DM_LDAP_BASE}`; const user2 = `uid=user2,${process.env.DM_LDAP_BASE}`; before(async () => { server = new DM(); process.env.DM_GROUP_SCHEMA = join( dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'static', 'schemas', 'twake', 'groups.json' ); plugin = new LdapGroups(server); const entry = { objectClass: ['inetOrgPerson', 'organizationalPerson', 'person', 'top'], cn: 'Test User', sn: 'User', uid: 'testuser', mail: 'test@test.org', }; await plugin.ldap.add(user1, entry); await plugin.ldap.add(user2, { ...entry, mail: 'test2@test.org' }); }); after(async () => { try { await plugin.ldap.delete(user1); await plugin.ldap.delete(user2); } catch (e) { // ignore } }); afterEach(async () => { try { await plugin.deleteGroup('testgroup4').catch(() => {}); await plugin.deleteGroup('testgroup3').catch(() => {}); } catch (e) { console.error('After error', e); } }); describe('constructor', () => { it('should set base from config', () => { expect(plugin.base).to.equal(DM_LDAP_GROUP_BASE); }); }); describe('New group', () => { it('should add/delete group with members', async () => { await plugin.addGroup('testgroup4', [user1], twakeAttr); const listEntries = await plugin.listGroups(); // @ts-ignore expect(listEntries).to.have.property('testgroup4'); expect(await plugin.searchGroupsByName('testgroup4')).to.deep.equal({ testgroup4: { dn: `cn=testgroup4,${DM_LDAP_GROUP_BASE}`, cn: 'testgroup4', member: [user1], }, }); expect(await plugin.deleteGroup('testgroup4')).to.be.true; }); }); }); linagora-ldap-rest-16e557e/test/plugins/ldap/organizations.test.ts000066400000000000000000000731331522642357000253540ustar00rootroot00000000000000import { expect } from 'chai'; import LdapOrganizations from '../../../src/plugins/ldap/organizations'; import { DM } from '../../../src/bin'; import supertest from 'supertest'; import type { SearchResult } from 'ldapts'; import { skipIfMissingEnvVars, LDAP_ENV_VARS_WITH_ORG, } from '../../helpers/env'; describe('LDAP Organizations Plugin', function () { before(function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS_WITH_ORG]); }); let server: DM; let plugin: LdapOrganizations; let request: any; let testOrgDn: string; let testSubOrgDn: string; let DM_LDAP_TOP_ORGANIZATION: string | undefined; let DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE: string | undefined; let DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE: string | undefined; before(async () => { // Initialize from env vars after they're set DM_LDAP_TOP_ORGANIZATION = process.env.DM_LDAP_TOP_ORGANIZATION; DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE = process.env.DM_LDAP_ORGANIZATION_LINK_ATTRIBUTE; DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE = process.env.DM_LDAP_ORGANIZATION_PATH_ATTRIBUTE; testOrgDn = `ou=testorg,${DM_LDAP_TOP_ORGANIZATION}`; testSubOrgDn = `ou=testsuborg,${DM_LDAP_TOP_ORGANIZATION}`; server = new DM(); await server.ready; plugin = new LdapOrganizations(server); await server.registerPlugin('ldapOrganizations', plugin); plugin.api(server.app); request = supertest(server.app); }); afterEach(async () => { // Clean up test organizations try { await plugin.server.ldap.delete(testOrgDn); } catch (e) { // ignore } try { await plugin.server.ldap.delete(testSubOrgDn); } catch (e) { // ignore } }); describe('constructor', () => { it('should throw error when ldap_top_organization is missing', () => { const tempServer = new DM(); const originalConfig = tempServer.config.ldap_top_organization; delete tempServer.config.ldap_top_organization; expect(() => new LdapOrganizations(tempServer)).to.throw( 'Missing --ldap-top-organization' ); tempServer.config.ldap_top_organization = originalConfig; }); it('should set pathAttr and linkAttr from config', () => { expect(plugin.pathAttr).to.equal( server.config.ldap_organization_path_attribute ); expect(plugin.linkAttr).to.equal( server.config.ldap_organization_link_attribute ); }); }); describe('isOu', () => { it('should return true for organizational unit entries', () => { const entry = { objectClass: ['twakedepartment', 'organizationalunit', 'top'], ou: 'testorg', }; expect(plugin.isOu(entry)).to.be.true; }); it('should return false for non-organizational entries', () => { const entry = { objectClass: ['inetOrgPerson', 'top'], cn: 'Test User', }; expect(plugin.isOu(entry)).to.be.false; }); it('should ignore "top" objectClass', () => { const entry = { objectClass: ['top'], cn: 'Test', }; expect(plugin.isOu(entry)).to.be.false; }); }); describe('getOrganisationTop', () => { it('should return top organization', async () => { const top = await plugin.getOrganisationTop(); if (Array.isArray(top)) { expect(top.length).to.be.greaterThan(0); expect(top[0]).to.have.property('dn'); } else { expect(top).to.have.property('dn'); expect((top.dn as string).toLowerCase()).to.equal( DM_LDAP_TOP_ORGANIZATION?.toLowerCase() ); } }); it('should throw error when top organization not configured', async () => { const tempServer = new DM(); delete tempServer.config.ldap_top_organization; try { const tempPlugin = new LdapOrganizations(tempServer); await tempPlugin.getOrganisationTop(); expect.fail('Should have thrown error'); } catch (e) { expect((e as Error).message).to.match( /Missing --ldap-top-organization|No top organization configured/ ); } }); }); describe('getOrganisationByDn', () => { beforeEach(async () => { // Create test organization await plugin.server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'top'], ou: 'testorg', }); }); it('should return organization by DN', async () => { const org = await plugin.getOrganisationByDn(testOrgDn); expect(org).to.have.property('dn', testOrgDn); expect(org).to.have.property('ou'); }); it('should throw error for non-existent organization', async () => { try { await plugin.getOrganisationByDn( `ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}` ); expect.fail('Should have thrown error'); } catch (e) { expect((e as Error).message).to.match(/not found|Code: 0x20/); } }); }); describe('getOrganisationSubnodes', () => { it('should return empty array when no subnodes exist', async function () { // Create test organization await plugin.server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'top'], ou: 'testorg', }); const subnodes = await plugin.getOrganisationSubnodes(testOrgDn); expect(subnodes).to.be.an('array').that.is.empty; }); it('should return empty array when no entries link to organization', async () => { // Create parent organization await plugin.server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'top'], ou: 'testorg', }); // Since twakeDepartmentLink may not exist in schema, // verify that getOrganisationSubnodes returns empty array // when no entries have this attribute pointing to the org const subnodes = await plugin.getOrganisationSubnodes(testOrgDn); expect(subnodes).to.be.an('array').that.is.empty; }); }); describe('checkDeptLink', () => { beforeEach(async () => { // Create test organization await plugin.server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'top'], ou: 'testorg', }); }); it('should accept valid organization link', async () => { const linkAttr = server.config.ldap_organization_link_attribute as string; const entry = { [linkAttr]: [testOrgDn], }; await plugin.checkDeptLink(entry); }); it('should reject non-existent organization link', async function () { const linkAttr = server.config.ldap_organization_link_attribute as string; const entry = { [linkAttr]: [`ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}`], }; try { await plugin.checkDeptLink(entry); expect.fail('Should have thrown error'); } catch (e) { // Accept both "does not exist" and LDAP error codes for non-existent entries expect((e as Error).message).to.match(/does not exist|Code: 0x20/); } }); it('should allow entry without organization link', async () => { const entry = { cn: 'test' }; await plugin.checkDeptLink(entry); }); }); describe('checkDeptPath', () => { it('should allow entry without organization path', async () => { const entry = { cn: 'test' }; await plugin.checkDeptPath(entry); }); }); describe('isEmptyOrganization', () => { beforeEach(async () => { await plugin.server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'top'], ou: 'testorg', }); }); it('should not throw for empty organization', async () => { await plugin.isEmptyOrganization(testOrgDn); }); it('should not throw when organization has no linked entries', async () => { // Since twakeDepartmentLink attribute may not exist in schema, // this test verifies that isEmptyOrganization correctly identifies // an organization as empty when no entries link to it await plugin.isEmptyOrganization(testOrgDn); // If we get here without exception, the organization is correctly identified as empty }); }); describe('hooks', () => { describe('ldapaddrequest', () => { it('should validate organization link on add', async () => { const linkAttr = server.config .ldap_organization_link_attribute as string; const entry = { [linkAttr]: [`ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}`], }; const dn = `uid=testuser3,${process.env.DM_LDAP_BASE}`; try { await plugin.hooks.ldapaddrequest?.([dn, entry]); expect.fail('Should have thrown error'); } catch (e) { // Accept both "does not exist" and LDAP error codes expect((e as Error).message).to.match(/does not exist|Code: 0x20/); } }); it('should allow add without organization attributes', async () => { const entry = { cn: 'test' }; const dn = `cn=test,${process.env.DM_LDAP_BASE}`; const result = await plugin.hooks.ldapaddrequest?.([dn, entry]); expect(result).to.deep.equal([dn, entry]); }); }); describe('ldapmodifyrequest', () => { it('should prevent deletion of organization link attribute for users/groups', async () => { // Create a test user first const userDn = `uid=testuser,${process.env.DM_LDAP_BASE}`; await plugin.server.ldap.add(userDn, { objectClass: ['inetOrgPerson', 'person', 'top'], uid: 'testuser', cn: 'Test User', sn: 'User', }); const linkAttr = server.config .ldap_organization_link_attribute as string; const changes = { delete: [linkAttr], }; try { await plugin.hooks.ldapmodifyrequest?.([userDn, changes, 0]); expect.fail('Should have thrown error'); } catch (e) { expect((e as Error).message).to.match( /organization link cannot be deleted/ ); } // Clean up await plugin.server.ldap.delete(userDn); }); it('should prevent deletion of organization path attribute', async () => { // Create organization first await plugin.server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'top'], ou: 'testorg', }); const pathAttr = server.config .ldap_organization_path_attribute as string; const dn = testOrgDn; const changes = { delete: [pathAttr], }; try { await plugin.hooks.ldapmodifyrequest?.([dn, changes, 0]); expect.fail('Should have thrown error'); } catch (e) { expect((e as Error).message).to.match( /organization path cannot be deleted/ ); } // Clean up await plugin.server.ldap.delete(testOrgDn); }); it('should allow modification without organization attributes', async () => { const dn = testOrgDn; const changes = { replace: { description: 'test' } }; const result = await plugin.hooks.ldapmodifyrequest?.([dn, changes, 0]); expect(result).to.deep.equal([dn, changes, 0]); }); }); describe('ldapdeleterequest', () => { it('should allow deletion when no entries link to organization', async () => { // Create organization await plugin.server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'top'], ou: 'testorg', }); // Since twakeDepartmentLink may not exist in schema, // verify that hook allows deletion when no entries link to org const result = await plugin.hooks.ldapdeleterequest?.([testOrgDn]); expect(result).to.deep.equal([testOrgDn, undefined]); }); it('should allow deletion of empty organization', async () => { await plugin.server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'top'], ou: 'testorg', }); const result = await plugin.hooks.ldapdeleterequest?.([testOrgDn]); expect(result).to.deep.equal([testOrgDn, undefined]); }); it('should allow deletion of non-organization entries', async () => { const dn = `cn=test,${process.env.DM_LDAP_BASE}`; const result = await plugin.hooks.ldapdeleterequest?.([dn]); expect(result).to.deep.equal([dn, undefined]); }); }); describe('ldaprenamerequest', () => { it('should pass through rename requests', () => { const dn = testOrgDn; const newdn = testSubOrgDn; const result = plugin.hooks.ldaprenamerequest?.([dn, newdn]); expect(result).to.deep.equal([dn, newdn]); }); }); }); describe('moveOrganization', () => { let parentOrgDn: string; let childOrgDn: string; let targetOrgDn: string; beforeEach(async () => { parentOrgDn = `ou=parent,${DM_LDAP_TOP_ORGANIZATION}`; childOrgDn = `ou=child,${parentOrgDn}`; targetOrgDn = `ou=target,${DM_LDAP_TOP_ORGANIZATION}`; // Create parent organization await plugin.server.ldap.add(parentOrgDn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'parent', }); // Create child organization under parent await plugin.server.ldap.add(childOrgDn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'child', }); // Create target organization await plugin.server.ldap.add(targetOrgDn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'target', }); }); afterEach(async () => { // Clean up in reverse order (child first) const newChildDn = `ou=child,${targetOrgDn}`; try { await plugin.server.ldap.delete(newChildDn); } catch (e) { // ignore } try { await plugin.server.ldap.delete(childOrgDn); } catch (e) { // ignore } try { await plugin.server.ldap.delete(parentOrgDn); } catch (e) { // ignore } try { await plugin.server.ldap.delete(targetOrgDn); } catch (e) { // ignore } }); it('should move organization to different parent', async () => { const result = await plugin.moveOrganization(childOrgDn, targetOrgDn); expect(result).to.have.property('newDn'); expect(result.newDn).to.equal(`ou=child,${targetOrgDn}`); // Verify organization was moved const movedOrg = await plugin.getOrganisationByDn(result.newDn); expect(movedOrg).to.have.property('ou', 'child'); // Verify old DN no longer exists try { await plugin.getOrganisationByDn(childOrgDn); expect.fail('Old DN should not exist'); } catch (e) { expect((e as Error).message).to.match(/not found|Code: 0x20/); } }); it('should reject move to non-existent target', async () => { const invalidTarget = `ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}`; try { await plugin.moveOrganization(childOrgDn, invalidTarget); expect.fail('Should have thrown error'); } catch (e) { expect((e as Error).message).to.match( /Target organization .* not found/ ); } }); it('should reject move of non-existent organization', async () => { const invalidSource = `ou=nonexistent,${parentOrgDn}`; try { await plugin.moveOrganization(invalidSource, targetOrgDn); expect.fail('Should have thrown error'); } catch (e) { expect((e as Error).message).to.match(/Source organization not found/); } }); it('should reject move to same location', async () => { try { await plugin.moveOrganization(childOrgDn, parentOrgDn); expect.fail('Should have thrown error'); } catch (e) { expect((e as Error).message).to.match( /Cannot move organization to its current location/ ); } }); it('should reject circular move (move into itself)', async () => { try { await plugin.moveOrganization(parentOrgDn, childOrgDn); expect.fail('Should have thrown error'); } catch (e) { expect((e as Error).message).to.match( /Cannot move organization into itself or its descendant/ ); } }); it('should reject move into itself', async () => { try { await plugin.moveOrganization(parentOrgDn, parentOrgDn); expect.fail('Should have thrown error'); } catch (e) { expect((e as Error).message).to.match( /Cannot move organization into itself or its descendant/ ); } }); it('should reject move to non-organizational target', async () => { // Create a non-organizational entry const userDn = `uid=testuser,${process.env.DM_LDAP_BASE}`; await plugin.server.ldap.add(userDn, { objectClass: ['inetOrgPerson', 'person', 'top'], uid: 'testuser', cn: 'Test User', sn: 'User', }); try { await plugin.moveOrganization(childOrgDn, userDn); expect.fail('Should have thrown error'); } catch (e) { expect((e as Error).message).to.match(/is not an organizational unit/); } // Clean up await plugin.server.ldap.delete(userDn); }); }); describe('API', () => { describe('GET /api/v1/ldap/organizations/top', () => { it('should return top organization', async () => { const res = await request .get('/api/v1/ldap/organizations/top') .set('Accept', 'application/json'); expect(res.status).to.equal(200); expect(res.body).to.have.property('dn'); expect(res.body.dn.toLowerCase()).to.equal( DM_LDAP_TOP_ORGANIZATION?.toLowerCase() ); }); }); describe('GET /api/v1/ldap/organizations/:dn', () => { beforeEach(async () => { await plugin.server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'top'], ou: 'testorg', }); }); it('should return organization by DN', async () => { const res = await request .get(`/api/v1/ldap/organizations/${encodeURIComponent(testOrgDn)}`) .set('Accept', 'application/json'); expect(res.status).to.equal(200); expect(res.body).to.have.property('dn', testOrgDn); expect(res.body).to.have.property('ou'); }); it('should return error for non-existent organization', async () => { const res = await request .get( `/api/v1/ldap/organizations/${encodeURIComponent(`ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}`)}` ) .set('Accept', 'application/json'); expect(res.status).to.equal(500); }); }); describe('GET /api/v1/ldap/organizations/:dn/subnodes', () => { beforeEach(async () => { await plugin.server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'top'], ou: 'testorg', }); }); it('should return empty array when organization has no linked entries', async () => { const res = await request .get( `/api/v1/ldap/organizations/${encodeURIComponent(testOrgDn)}/subnodes` ) .set('Accept', 'application/json'); expect(res.status).to.equal(200); expect(res.body).to.be.an('array').that.is.empty; }); }); describe('POST /api/v1/ldap/organizations', () => { it('should create a new organization', async () => { const res = await request .post('/api/v1/ldap/organizations') .type('json') .send({ ou: 'testorg', }); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ success: true }); // Verify organization was created const org = await plugin.getOrganisationByDn(testOrgDn); expect(org).to.have.property('ou', 'testorg'); }); it('should return error when ou is missing', async () => { const res = await request .post('/api/v1/ldap/organizations') .type('json') .send({}); expect(res.status).to.equal(400); }); it('should create a sub-organization with parentDn', async () => { // First create parent organization await plugin.server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'top'], ou: 'testorg', }); const subOrgRes = await request .post('/api/v1/ldap/organizations') .type('json') .send({ ou: 'testsuborg', parentDn: testOrgDn, }); expect(subOrgRes.status).to.equal(200); expect(subOrgRes.body).to.deep.equal({ success: true }); // Verify sub-organization was created under parent const subOrgDn = `ou=testsuborg,${testOrgDn}`; const subOrg = await plugin.getOrganisationByDn(subOrgDn); expect(subOrg).to.have.property('ou', 'testsuborg'); // Clean up sub-org await plugin.server.ldap.delete(subOrgDn); }); }); describe('PUT /api/v1/ldap/organizations/:dn', () => { beforeEach(async () => { await plugin.server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'top'], ou: 'testorg', }); }); it('should modify an organization', async () => { const res = await request .put(`/api/v1/ldap/organizations/${encodeURIComponent(testOrgDn)}`) .type('json') .send({ replace: { description: 'Test organization description' }, }); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ success: true }); // Verify modification const org = await plugin.getOrganisationByDn(testOrgDn); expect(org).to.have.property( 'description', 'Test organization description' ); }); it('should return error for invalid dn', async () => { const res = await request .put( `/api/v1/ldap/organizations/${encodeURIComponent(`ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}`)}` ) .type('json') .send({ replace: { description: 'Test' }, }); expect(res.status).to.equal(500); }); }); describe('DELETE /api/v1/ldap/organizations/:dn', () => { beforeEach(async () => { await plugin.server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'top'], ou: 'testorg', }); }); it('should delete an empty organization', async () => { const res = await request .delete(`/api/v1/ldap/organizations/${encodeURIComponent(testOrgDn)}`) .set('Accept', 'application/json'); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ success: true }); // Verify organization was deleted try { await plugin.getOrganisationByDn(testOrgDn); expect.fail('Organization should have been deleted'); } catch (e) { expect((e as Error).message).to.match(/not found|Code: 0x20/); } }); it('should return error when deleting non-existent organization', async () => { const res = await request .delete( `/api/v1/ldap/organizations/${encodeURIComponent(`ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}`)}` ) .set('Accept', 'application/json'); expect(res.status).to.equal(500); }); }); describe('POST /api/v1/ldap/organizations/:dn/move', () => { let parentOrgDn: string; let childOrgDn: string; let targetOrgDn: string; beforeEach(async () => { parentOrgDn = `ou=parent,${DM_LDAP_TOP_ORGANIZATION}`; childOrgDn = `ou=child,${parentOrgDn}`; targetOrgDn = `ou=target,${DM_LDAP_TOP_ORGANIZATION}`; // Create parent organization await plugin.server.ldap.add(parentOrgDn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'parent', }); // Create child organization under parent await plugin.server.ldap.add(childOrgDn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'child', }); // Create target organization await plugin.server.ldap.add(targetOrgDn, { objectClass: ['organizationalUnit', 'twakeDepartment', 'top'], ou: 'target', }); }); afterEach(async () => { // Clean up in reverse order (child first) const newChildDn = `ou=child,${targetOrgDn}`; try { await plugin.server.ldap.delete(newChildDn); } catch (e) { // ignore } try { await plugin.server.ldap.delete(childOrgDn); } catch (e) { // ignore } try { await plugin.server.ldap.delete(parentOrgDn); } catch (e) { // ignore } try { await plugin.server.ldap.delete(targetOrgDn); } catch (e) { // ignore } }); it('should move organization via API', async () => { const res = await request .post( `/api/v1/ldap/organizations/${encodeURIComponent(childOrgDn)}/move` ) .type('json') .send({ targetOrgDn, }); expect(res.status).to.equal(200); expect(res.body).to.have.property('newDn', `ou=child,${targetOrgDn}`); // Verify organization was moved const movedOrg = await plugin.getOrganisationByDn(res.body.newDn); expect(movedOrg).to.have.property('ou', 'child'); }); it('should return error when targetOrgDn is missing', async () => { const res = await request .post( `/api/v1/ldap/organizations/${encodeURIComponent(childOrgDn)}/move` ) .type('json') .send({}); expect(res.status).to.equal(400); expect(res.text).to.match(/Bad content/); }); it('should return error when targetOrgDn is invalid', async () => { const res = await request .post( `/api/v1/ldap/organizations/${encodeURIComponent(childOrgDn)}/move` ) .type('json') .send({ targetOrgDn: 123, // Invalid type }); expect(res.status).to.equal(400); expect(res.text).to.match(/Missing or invalid targetOrgDn/); }); it('should return 404 when target does not exist', async () => { const res = await request .post( `/api/v1/ldap/organizations/${encodeURIComponent(childOrgDn)}/move` ) .type('json') .send({ targetOrgDn: `ou=nonexistent,${DM_LDAP_TOP_ORGANIZATION}`, }); expect(res.status).to.equal(404); expect(res.body.error).to.match(/Target organization .* not found/); }); it('should return 404 when source does not exist', async () => { const res = await request .post( `/api/v1/ldap/organizations/${encodeURIComponent(`ou=nonexistent,${parentOrgDn}`)}/move` ) .type('json') .send({ targetOrgDn, }); expect(res.status).to.equal(404); expect(res.body.error).to.match(/Source organization/); }); it('should return 400 for circular move', async () => { const res = await request .post( `/api/v1/ldap/organizations/${encodeURIComponent(parentOrgDn)}/move` ) .type('json') .send({ targetOrgDn: childOrgDn, }); expect(res.status).to.equal(400); expect(res.body.error).to.match(/itself or its descendant/); }); it('should return 400 when moving to same location', async () => { const res = await request .post( `/api/v1/ldap/organizations/${encodeURIComponent(childOrgDn)}/move` ) .type('json') .send({ targetOrgDn: parentOrgDn, }); expect(res.status).to.equal(400); expect(res.body.error).to.match(/current location/); }); it('should reject move into self when DNs differ only in case', async () => { // Same DN but different attribute-name casing — must still be // recognised as the same node, not as a child of itself. const upperCased = childOrgDn.replace(/^ou=/, 'OU='); const res = await request .post( `/api/v1/ldap/organizations/${encodeURIComponent(childOrgDn)}/move` ) .type('json') .send({ targetOrgDn: upperCased }); expect(res.status).to.equal(400); expect(res.body.error).to.match( /itself or its descendant|current location/ ); }); }); describe('GET /api/v1/ldap/organizations/:dn/subnodes (LDAP injection)', () => { beforeEach(async () => { await plugin.server.ldap.add(testOrgDn, { objectClass: ['organizationalUnit', 'top'], ou: 'testorg', }); }); it('should escape LDAP filter metacharacters in objectClass query', async () => { // Without escaping, "*)(uid=*" would close the link-attribute // clause and inject `(uid=*)` — leaking unrelated entries. // With escaping, the value is treated as a literal objectClass // and the search returns an empty result set without errors. const res = await request .get( `/api/v1/ldap/organizations/${encodeURIComponent(testOrgDn)}/subnodes` ) .query({ objectClass: '*)(uid=*' }) .set('Accept', 'application/json'); expect(res.status).to.equal(200); expect(res.body).to.be.an('array').that.is.empty; }); }); }); }); linagora-ldap-rest-16e557e/test/plugins/ldap/passwordPolicy.test.ts000066400000000000000000000256561522642357000255160ustar00rootroot00000000000000import { expect } from 'chai'; import supertest from 'supertest'; import { DM } from '../../../src/bin'; import PasswordPolicy from '../../../src/plugins/ldap/passwordPolicy'; import { skipIfMissingEnvVars, LDAP_ENV_VARS } from '../../helpers/env'; describe('PasswordPolicy Plugin', () => { let dm: DM; before(function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS]); }); beforeEach(async () => { dm = new DM(); await dm.ready; }); afterEach(async () => { if (dm) { await dm.stop(); } }); describe('API Endpoints', () => { it('should expose GET /password-policy endpoint', async () => { const plugin = new PasswordPolicy(dm); dm.registerPlugin('passwordPolicy', plugin); const request = supertest(dm.app); const response = await request .get('/api/v1/password-policy') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body).to.be.an('object'); // Policy may be empty if ppolicy overlay not configured }); it('should return 404 for non-existent user password status', async () => { const plugin = new PasswordPolicy(dm); dm.registerPlugin('passwordPolicy', plugin); const request = supertest(dm.app); const response = await request .get('/api/v1/users/nonexistent-user-12345/password-status') .set('Accept', 'application/json'); expect(response.status).to.equal(404); }); it('should expose GET /password-policy/expiring-soon endpoint', async () => { const plugin = new PasswordPolicy(dm); dm.registerPlugin('passwordPolicy', plugin); const request = supertest(dm.app); const response = await request .get('/api/v1/password-policy/expiring-soon') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body).to.have.property('warningDays'); expect(response.body).to.have.property('users'); expect(response.body.users).to.be.an('array'); }); it('should accept days parameter for expiring-soon', async () => { const plugin = new PasswordPolicy(dm); dm.registerPlugin('passwordPolicy', plugin); const request = supertest(dm.app); const response = await request .get('/api/v1/password-policy/expiring-soon?days=7') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body.warningDays).to.equal(7); }); it('should expose GET /password-policy/locked-accounts endpoint', async () => { const plugin = new PasswordPolicy(dm); dm.registerPlugin('passwordPolicy', plugin); const request = supertest(dm.app); const response = await request .get('/api/v1/password-policy/locked-accounts') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body).to.have.property('accounts'); expect(response.body.accounts).to.be.an('array'); }); }); describe('Password Validation (when enabled)', () => { it('should expose POST /password/validate when ppolicy_validate_complexity is true', async () => { dm.config.ppolicy_validate_complexity = true; const plugin = new PasswordPolicy(dm); dm.registerPlugin('passwordPolicy', plugin); const request = supertest(dm.app); const response = await request .post('/api/v1/password/validate') .send({ password: 'Test123!@#Strong' }) .set('Content-Type', 'application/json') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body).to.have.property('valid'); expect(response.body).to.have.property('errors'); }); it('should reject password too short', async () => { dm.config.ppolicy_validate_complexity = true; dm.config.ppolicy_min_length = 12; const plugin = new PasswordPolicy(dm); dm.registerPlugin('passwordPolicy', plugin); const request = supertest(dm.app); const response = await request .post('/api/v1/password/validate') .send({ password: 'Short1!' }) .set('Content-Type', 'application/json') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body.valid).to.be.false; expect(response.body.errors).to.include('Minimum 12 characters required'); }); it('should reject password without uppercase', async () => { dm.config.ppolicy_validate_complexity = true; dm.config.ppolicy_require_uppercase = true; const plugin = new PasswordPolicy(dm); dm.registerPlugin('passwordPolicy', plugin); const request = supertest(dm.app); const response = await request .post('/api/v1/password/validate') .send({ password: 'lowercaseonly123!' }) .set('Content-Type', 'application/json') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body.valid).to.be.false; expect(response.body.errors).to.include( 'At least one uppercase letter required' ); }); it('should reject password without lowercase', async () => { dm.config.ppolicy_validate_complexity = true; dm.config.ppolicy_require_lowercase = true; const plugin = new PasswordPolicy(dm); dm.registerPlugin('passwordPolicy', plugin); const request = supertest(dm.app); const response = await request .post('/api/v1/password/validate') .send({ password: 'UPPERCASEONLY123!' }) .set('Content-Type', 'application/json') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body.valid).to.be.false; expect(response.body.errors).to.include( 'At least one lowercase letter required' ); }); it('should reject password without digit', async () => { dm.config.ppolicy_validate_complexity = true; dm.config.ppolicy_require_digit = true; const plugin = new PasswordPolicy(dm); dm.registerPlugin('passwordPolicy', plugin); const request = supertest(dm.app); const response = await request .post('/api/v1/password/validate') .send({ password: 'NoDigitsHere!@#' }) .set('Content-Type', 'application/json') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body.valid).to.be.false; expect(response.body.errors).to.include('At least one digit required'); }); it('should reject password without special character', async () => { dm.config.ppolicy_validate_complexity = true; dm.config.ppolicy_require_special = true; const plugin = new PasswordPolicy(dm); dm.registerPlugin('passwordPolicy', plugin); const request = supertest(dm.app); const response = await request .post('/api/v1/password/validate') .send({ password: 'NoSpecialChars123' }) .set('Content-Type', 'application/json') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body.valid).to.be.false; expect(response.body.errors).to.include( 'At least one special character required' ); }); it('should accept valid password', async () => { dm.config.ppolicy_validate_complexity = true; dm.config.ppolicy_min_length = 12; dm.config.ppolicy_require_uppercase = true; dm.config.ppolicy_require_lowercase = true; dm.config.ppolicy_require_digit = true; dm.config.ppolicy_require_special = true; const plugin = new PasswordPolicy(dm); dm.registerPlugin('passwordPolicy', plugin); const request = supertest(dm.app); const response = await request .post('/api/v1/password/validate') .send({ password: 'ValidP@ssw0rd123!' }) .set('Content-Type', 'application/json') .set('Accept', 'application/json'); expect(response.status).to.equal(200); expect(response.body.valid).to.be.true; expect(response.body.errors).to.be.empty; }); it('should return 400 when password is missing', async () => { dm.config.ppolicy_validate_complexity = true; const plugin = new PasswordPolicy(dm); dm.registerPlugin('passwordPolicy', plugin); const request = supertest(dm.app); const response = await request .post('/api/v1/password/validate') .send({}) .set('Content-Type', 'application/json') .set('Accept', 'application/json'); expect(response.status).to.equal(400); }); }); describe('Config API Data', () => { it('should provide config API data', () => { const plugin = new PasswordPolicy(dm); const configData = plugin.getConfigApiData(); expect(configData).to.exist; expect(configData).to.have.property('name', 'ldapPasswordPolicy'); expect(configData).to.have.property('enabled', true); expect(configData).to.have.property('endpoints'); expect(configData).to.have.property('config'); }); it('should include validatePassword endpoint when enabled', () => { dm.config.ppolicy_validate_complexity = true; const plugin = new PasswordPolicy(dm); const configData = plugin.getConfigApiData() as Record; const endpoints = configData?.endpoints as Record; expect(endpoints).to.have.property('validatePassword'); expect(endpoints?.validatePassword).to.not.be.undefined; }); it('should not include validatePassword endpoint when disabled', () => { dm.config.ppolicy_validate_complexity = false; const plugin = new PasswordPolicy(dm); const configData = plugin.getConfigApiData() as Record; const endpoints = configData?.endpoints as Record; expect(endpoints?.validatePassword).to.be.undefined; }); it('should include complexityRules when validation enabled', () => { dm.config.ppolicy_validate_complexity = true; dm.config.ppolicy_min_length = 16; dm.config.ppolicy_require_uppercase = true; dm.config.ppolicy_require_lowercase = false; const plugin = new PasswordPolicy(dm); const configData = plugin.getConfigApiData() as Record; const config = configData?.config as Record; const rules = config?.complexityRules as Record; expect(rules).to.exist; expect(rules.minLength).to.equal(16); expect(rules.requireUppercase).to.be.true; expect(rules.requireLowercase).to.be.false; }); it('should not include complexityRules when validation disabled', () => { dm.config.ppolicy_validate_complexity = false; const plugin = new PasswordPolicy(dm); const configData = plugin.getConfigApiData() as Record; const config = configData?.config as Record; expect(config?.complexityRules).to.be.undefined; }); }); }); linagora-ldap-rest-16e557e/test/plugins/ldap/trash.test.ts000066400000000000000000000154201522642357000236010ustar00rootroot00000000000000import { expect } from 'chai'; import { DM } from '../../../src/bin'; import TrashPlugin from '../../../src/plugins/ldap/trash'; describe('Trash Plugin', function () { // Skip all tests if required env vars are not set if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_BASE ) { // eslint-disable-next-line no-console console.warn( 'Skipping trash tests: DM_LDAP_BASE, DM_LDAP_DN, and DM_LDAP_PWD env vars are required' ); // @ts-ignore this.skip?.(); return; } let server: DM; let plugin: TrashPlugin; const testUser = `testtrash${Date.now()}`; const userDn = `uid=${testUser},${process.env.DM_LDAP_BASE}`; const trashBase = process.env.DM_TRASH_BASE || `ou=trash,${process.env.DM_LDAP_BASE}`; const trashDn = `uid=${testUser},${trashBase}`; before(async function () { this.timeout(10000); // Set up trash configuration process.env.DM_TRASH_BASE = trashBase; process.env.DM_TRASH_WATCHED_BASES = process.env.DM_LDAP_BASE; process.env.DM_TRASH_ADD_METADATA = 'true'; process.env.DM_TRASH_AUTO_CREATE = 'true'; server = new DM(); await server.ready; plugin = new TrashPlugin(server); // Register plugin to activate hooks await server.registerPlugin('trash', plugin); }); after(async () => { // Clean up: try to delete test entries from both locations try { await server.ldap.delete(userDn); } catch (e) { // ignore } try { await server.ldap.delete(trashDn); } catch (e) { // ignore } }); describe('Plugin initialization', () => { it('should initialize with correct config', () => { expect(plugin.name).to.equal('trash'); }); it('should create trash branch if it does not exist', async () => { // This is tested implicitly by the plugin working // We'll verify it exists in subsequent tests }); }); describe('Delete interception', () => { beforeEach(async function () { this.timeout(5000); // Create test user await server.ldap.add(userDn, { objectClass: ['inetOrgPerson', 'organizationalPerson', 'person', 'top'], cn: 'Test Trash User', sn: 'Trash', uid: testUser, mail: `${testUser}@test.org`, }); }); afterEach(async () => { // Clean up try { await server.ldap.delete(userDn); } catch (e) { // ignore } try { await server.ldap.delete(trashDn); } catch (e) { // ignore } }); it('should move deleted user to trash instead of deleting', async function () { this.timeout(10000); // Delete user (should be intercepted by trash plugin) await server.ldap.delete(userDn); // Verify user no longer exists in original location try { await server.ldap.search({ paged: false }, userDn); expect.fail('User should not exist in original location'); } catch (error) { // Expected: user not found } // Verify user exists in trash const trashResult = await server.ldap.search({ paged: false }, trashDn); // @ts-ignore expect(trashResult.searchEntries).to.have.lengthOf(1); // @ts-ignore expect(trashResult.searchEntries[0].uid).to.equal(testUser); }); it('should add metadata to trash entry', async function () { this.timeout(10000); // Delete user await server.ldap.delete(userDn); // Check trash entry for metadata const trashResult = await server.ldap.search({ paged: false }, trashDn); // @ts-ignore const entry = trashResult.searchEntries[0]; expect(entry.description).to.be.a('string'); expect(entry.description).to.include('Deleted on'); expect(entry.description).to.include(userDn); }); it('should overwrite existing trash entry', async function () { this.timeout(15000); // First deletion await server.ldap.delete(userDn); // Verify entry is in trash let trashResult = await server.ldap.search({ paged: false }, trashDn); // @ts-ignore expect(trashResult.searchEntries).to.have.lengthOf(1); // @ts-ignore const firstDescription = trashResult.searchEntries[0].description; // Wait a bit to ensure different timestamp await new Promise(resolve => setTimeout(resolve, 1100)); // Manually delete trash entry to simulate cleanup // This is needed because we can't have two entries with same mail await server.ldap.delete(trashDn); // Recreate user await server.ldap.add(userDn, { objectClass: ['inetOrgPerson', 'organizationalPerson', 'person', 'top'], cn: 'Test Trash User Second', sn: 'Trash', uid: testUser, mail: `${testUser}@test.org`, }); // Delete again (should overwrite trash entry - but previous was manually deleted) await server.ldap.delete(userDn); // Verify entry is in trash trashResult = await server.ldap.search({ paged: false }, trashDn); // @ts-ignore expect(trashResult.searchEntries).to.have.lengthOf(1); // Verify metadata was updated (different timestamp) // @ts-ignore const secondDescription = trashResult.searchEntries[0].description; expect(secondDescription).to.not.equal(firstDescription); }); }); describe('Watched branches', () => { it('should not intercept deletes within trash itself', async function () { this.timeout(10000); // Create a user and move it to trash await server.ldap.add(userDn, { objectClass: ['inetOrgPerson', 'organizationalPerson', 'person', 'top'], cn: 'Test Trash User', sn: 'Trash', uid: testUser, mail: `${testUser}@test.org`, }); // Delete user (moves to trash) await server.ldap.delete(userDn); // Verify it's in trash let trashResult = await server.ldap.search({ paged: false }, trashDn); // @ts-ignore expect(trashResult.searchEntries).to.have.lengthOf(1); // Now delete directly from trash - should REALLY delete, not move again await server.ldap.delete(trashDn); // Verify it's really gone from trash try { trashResult = await server.ldap.search({ paged: false }, trashDn); // @ts-ignore expect(trashResult.searchEntries).to.have.lengthOf(0); } catch (error) { // Expected: entry not found } }); }); describe('Error handling', () => { it('should handle missing LDAP permissions gracefully', async function () { // This test would require intentionally limiting permissions // which is difficult to do in a unit test environment // In a real scenario, the plugin should throw a descriptive error // about insufficient permissions }); }); }); linagora-ldap-rest-16e557e/test/plugins/ldap/trashIntegration.test.ts000066400000000000000000000162111522642357000260040ustar00rootroot00000000000000import { expect } from 'chai'; import { DM } from '../../../src/bin'; import TrashPlugin from '../../../src/plugins/ldap/trash'; describe('Trash Plugin - Integration Tests', function () { // Skip all tests if required env vars are not set if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_BASE ) { // eslint-disable-next-line no-console console.warn( 'Skipping trash integration tests: DM_LDAP_BASE, DM_LDAP_DN, and DM_LDAP_PWD env vars are required' ); // @ts-ignore this.skip?.(); return; } let server: DM; let plugin: TrashPlugin; const testUser = `testintegtrash${Date.now()}`; const userDn = `uid=${testUser},${process.env.DM_LDAP_BASE}`; const trashBase = process.env.DM_TRASH_BASE || `ou=trash,${process.env.DM_LDAP_BASE}`; const trashDn = `uid=${testUser},${trashBase}`; before(async function () { this.timeout(10000); // Set up trash configuration process.env.DM_TRASH_BASE = trashBase; process.env.DM_TRASH_WATCHED_BASES = process.env.DM_LDAP_BASE; process.env.DM_TRASH_ADD_METADATA = 'true'; process.env.DM_TRASH_AUTO_CREATE = 'true'; server = new DM(); await server.ready; // Register trash plugin to intercept deletes plugin = new TrashPlugin(server); await server.registerPlugin('trash', plugin); }); after(async () => { // Clean up: try to delete test entries from both locations try { await server.ldap.delete(userDn); } catch (e) { // ignore } try { await server.ldap.delete(trashDn); } catch (e) { // ignore } }); describe('Multiple users deletion', () => { it('should handle deletion of multiple users in one call', async function () { this.timeout(10000); const testUser1 = `${testUser}1`; const testUser2 = `${testUser}2`; const userDn1 = `uid=${testUser1},${process.env.DM_LDAP_BASE}`; const userDn2 = `uid=${testUser2},${process.env.DM_LDAP_BASE}`; const trashDn1 = `uid=${testUser1},${trashBase}`; const trashDn2 = `uid=${testUser2},${trashBase}`; try { // Create two test users await server.ldap.add(userDn1, { objectClass: [ 'inetOrgPerson', 'organizationalPerson', 'person', 'top', ], cn: 'Test User 1', sn: 'Trash', uid: testUser1, mail: `${testUser1}@test.org`, }); await server.ldap.add(userDn2, { objectClass: [ 'inetOrgPerson', 'organizationalPerson', 'person', 'top', ], cn: 'Test User 2', sn: 'Trash', uid: testUser2, mail: `${testUser2}@test.org`, }); // Delete both users (should move both to trash) await server.ldap.delete([userDn1, userDn2]); // Verify both are in trash const trash1 = await server.ldap.search({ paged: false }, trashDn1); // @ts-ignore expect(trash1.searchEntries).to.have.lengthOf(1); const trash2 = await server.ldap.search({ paged: false }, trashDn2); // @ts-ignore expect(trash2.searchEntries).to.have.lengthOf(1); } finally { // Cleanup try { await server.ldap.delete(trashDn1); } catch (e) { // ignore } try { await server.ldap.delete(trashDn2); } catch (e) { // ignore } } }); }); describe('Configuration variations', () => { it('should respect DM_TRASH_ADD_METADATA=false', async function () { this.timeout(10000); // Create a new server with metadata disabled const testUser2 = `${testUser}_nometa`; const userDn2 = `uid=${testUser2},${process.env.DM_LDAP_BASE}`; const trashDn2 = `uid=${testUser2},${trashBase}`; process.env.DM_TRASH_ADD_METADATA = 'false'; const server2 = new DM(); await server2.ready; const plugin2 = new TrashPlugin(server2); await server2.registerPlugin('trash', plugin2); try { // Create test user await server2.ldap.add(userDn2, { objectClass: [ 'inetOrgPerson', 'organizationalPerson', 'person', 'top', ], cn: 'Test User No Meta', sn: 'Trash', uid: testUser2, mail: `${testUser2}@test.org`, }); // Delete user (moves to trash without metadata) await server2.ldap.delete(userDn2); // Verify it's in trash but without description metadata const trashResult = await server2.ldap.search( { paged: false }, trashDn2 ); // @ts-ignore expect(trashResult.searchEntries).to.have.lengthOf(1); // @ts-ignore const entry = trashResult.searchEntries[0]; // Description should not exist or not contain "Deleted on" if (entry.description) { expect(entry.description).to.not.include('Deleted on'); } } finally { // Cleanup try { await server2.ldap.delete(trashDn2); } catch (e) { // ignore } // Restore original setting process.env.DM_TRASH_ADD_METADATA = 'true'; } }); }); describe('Unmatched branches', () => { it('should NOT intercept deletes outside watched branches', async function () { this.timeout(10000); // Create a new server that only watches ou=users const testUser3 = `${testUser}_unwatched`; const userDn3 = `uid=${testUser3},${process.env.DM_LDAP_BASE}`; // Set watched branches to something that doesn't match our test base process.env.DM_TRASH_WATCHED_BASES = 'ou=users,dc=example,dc=com'; const server3 = new DM(); await server3.ready; const plugin3 = new TrashPlugin(server3); await server3.registerPlugin('trash', plugin3); try { // Create test user await server3.ldap.add(userDn3, { objectClass: [ 'inetOrgPerson', 'organizationalPerson', 'person', 'top', ], cn: 'Test User Unwatched', sn: 'Trash', uid: testUser3, mail: `${testUser3}@test.org`, }); // Delete user (should be permanently deleted, NOT moved to trash) await server3.ldap.delete(userDn3); // Verify it's really gone try { await server3.ldap.search({ paged: false }, userDn3); expect.fail('User should be permanently deleted'); } catch (error) { // Expected: user not found } // Verify it's NOT in trash const trashDn3 = `uid=${testUser3},${trashBase}`; try { const trashResult = await server3.ldap.search( { paged: false }, trashDn3 ); // @ts-ignore expect(trashResult.searchEntries).to.have.lengthOf(0); } catch (error) { // Expected: not in trash } } finally { // Restore original setting process.env.DM_TRASH_WATCHED_BASES = process.env.DM_LDAP_BASE; } }); }); }); linagora-ldap-rest-16e557e/test/plugins/pluginOverride.test.ts000066400000000000000000000020711522642357000245340ustar00rootroot00000000000000import { expect } from 'chai'; import supertest from 'supertest'; import { DM } from '../../src/bin'; describe('Plugin Override', () => { let dm: DM; before(async () => { process.env.NODE_ENV = 'test'; process.env.DM_PLUGINS = '../../dist/plugins/demo/helloworld.js;../../dist/plugins/demo/helloworld.js:myHello:{"api_prefix":"/myapi"}'; dm = new DM(); await dm.ready; }); it('should load the helloworld plugin and respond to /api/hello', async () => { const request = supertest(dm.app); const res = await request.get('/api/hello'); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ message: 'Hello', hookResults: [] }); }); it('should load the helloworld plugin and respond to /myapi/hello', async () => { const request = supertest(dm.app); const res = await request.get('/myapi/hello'); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ message: 'Hello', hookResults: [] }); }); after(() => { delete process.env.NODE_ENV; delete process.env.DM_PLUGINS; }); }); linagora-ldap-rest-16e557e/test/plugins/rabbitmq.test.ts000066400000000000000000000120341522642357000233370ustar00rootroot00000000000000import { expect } from 'chai'; import { DM } from '../../src/bin'; import RabbitMq from '../../src/plugins/rabbitmq'; interface PublishCall { exchange: string; routingKey: string; message: Record; options?: Record; } interface SubscribeCall { exchange: string; routingKey: string; queue: string; handler: (m: Record) => Promise; } class StubClient { publishCalls: PublishCall[] = []; subscribeCalls: SubscribeCall[] = []; unsubscribeCalls: string[] = []; closed = false; async init(): Promise {} async publish( exchange: string, routingKey: string, message: Record, options?: Record ): Promise { this.publishCalls.push({ exchange, routingKey, message, options }); } async subscribe( exchange: string, routingKey: string, queue: string, handler: (m: Record) => Promise ): Promise { this.subscribeCalls.push({ exchange, routingKey, queue, handler }); } async unsubscribe(queue: string): Promise { this.unsubscribeCalls.push(queue); } async close(): Promise { this.closed = true; } } class TestableRabbitMq extends RabbitMq { stub = new StubClient(); // eslint-disable-next-line @typescript-eslint/no-explicit-any protected async getClient(): Promise { return this.stub; } } /** * Exercises the real getClient() caching/retry policy by stubbing the * connect() seam: the first attempt fails, the second succeeds. */ class FlakyRabbitMq extends RabbitMq { stub = new StubClient(); connectAttempts = 0; // eslint-disable-next-line @typescript-eslint/no-explicit-any protected async connect(): Promise { this.connectAttempts++; if (this.connectAttempts === 1) throw new Error('broker down'); return this.stub; } } describe('RabbitMq plugin', () => { describe('with a configured broker URL', () => { let dm: DM; let plugin: TestableRabbitMq; beforeEach(async () => { dm = new DM(); dm.config.rabbitmq_url = 'amqp://guest:guest@rabbitmq:5672/'; await dm.ready; plugin = new TestableRabbitMq(dm); }); it('reports itself as available', () => { expect(plugin.isAvailable()).to.equal(true); }); it('delegates publish to the client', async () => { await plugin.publish('auth', 'user.created', { twakeId: 'alice' }); expect(plugin.stub.publishCalls).to.have.length(1); const call = plugin.stub.publishCalls[0]; expect(call.exchange).to.equal('auth'); expect(call.routingKey).to.equal('user.created'); expect(call.message).to.deep.equal({ twakeId: 'alice' }); }); it('forwards publish options to the client', async () => { await plugin.publish( 'auth', 'user.created', { twakeId: 'bob' }, { correlationId: 'abc' } ); expect(plugin.stub.publishCalls[0].options).to.deep.equal({ correlationId: 'abc', }); }); it('delegates subscribe to the client', async () => { const handler = async (): Promise => {}; await plugin.subscribe('b2b', 'domain.user.deleted', 'my-queue', handler); expect(plugin.stub.subscribeCalls).to.have.length(1); const call = plugin.stub.subscribeCalls[0]; expect(call.exchange).to.equal('b2b'); expect(call.routingKey).to.equal('domain.user.deleted'); expect(call.queue).to.equal('my-queue'); expect(call.handler).to.equal(handler); }); it('delegates unsubscribe to the client', async () => { await plugin.unsubscribe('my-queue'); expect(plugin.stub.unsubscribeCalls).to.deep.equal(['my-queue']); }); }); describe('connection retry', () => { let dm: DM; let plugin: FlakyRabbitMq; beforeEach(async () => { dm = new DM(); dm.config.rabbitmq_url = 'amqp://guest:guest@rabbitmq:5672/'; await dm.ready; plugin = new FlakyRabbitMq(dm); }); it('retries connecting after an initial failure', async () => { const first = await plugin.getRawClient(); expect(first, 'first connect fails → null').to.equal(null); const second = await plugin.getRawClient(); expect(second, 'second connect succeeds').to.equal(plugin.stub); expect(plugin.connectAttempts).to.equal(2); }); }); describe('with no broker URL configured', () => { let dm: DM; let plugin: RabbitMq; beforeEach(async () => { dm = new DM(); dm.config.rabbitmq_url = ''; await dm.ready; plugin = new RabbitMq(dm); }); it('reports itself as unavailable', () => { expect(plugin.isAvailable()).to.equal(false); }); it('no-ops publish without throwing', async () => { await plugin.publish('auth', 'user.created', { twakeId: 'alice' }); }); it('no-ops subscribe without throwing', async () => { await plugin.subscribe('b2b', 'k', 'q', async () => {}); }); it('no-ops unsubscribe without throwing', async () => { await plugin.unsubscribe('q'); }); }); }); linagora-ldap-rest-16e557e/test/plugins/scim/000077500000000000000000000000001522642357000211435ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/plugins/scim/baseResolver.test.ts000066400000000000000000000161561522642357000251360ustar00rootroot00000000000000import { expect } from 'chai'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { BaseResolver } from '../../../src/plugins/scim/baseResolver'; import type { Config } from '../../../src/config/args'; function cfg(overrides: Partial = {}): Config { return { port: 0, schemas_path: '', log_level: 'error', logger: 'console', api_prefix: '/api', ldap_base: 'dc=example,dc=com', ...overrides, } as Config; } describe('SCIM baseResolver', () => { it('falls back to ldap_base when nothing is configured', () => { const br = new BaseResolver(cfg()); expect(br.userBase()).to.equal('dc=example,dc=com'); expect(br.groupBase()).to.equal('dc=example,dc=com'); }); it('uses explicit static bases', () => { const br = new BaseResolver( cfg({ scim_user_base: 'ou=users,dc=example,dc=com', scim_group_base: 'ou=groups,dc=example,dc=com', }) ); expect(br.userBase()).to.equal('ou=users,dc=example,dc=com'); expect(br.groupBase()).to.equal('ou=groups,dc=example,dc=com'); }); it('applies {user} template', () => { const br = new BaseResolver( cfg({ scim_user_base_template: 'ou=users,ou={user},dc=example,dc=com', scim_group_base_template: 'ou=groups,ou={user},dc=example,dc=com', }) ); expect(br.userBase({ user: 'tenant1' })).to.equal( 'ou=users,ou=tenant1,dc=example,dc=com' ); expect(br.groupBase({ user: 'tenant1' })).to.equal( 'ou=groups,ou=tenant1,dc=example,dc=com' ); }); it('escapes DN special chars in {user}', () => { const br = new BaseResolver( cfg({ scim_user_base_template: 'ou={user},dc=x' }) ); expect(br.userBase({ user: 'evil,dc=bad' })).to.equal( 'ou=evil\\,dc\\=bad,dc=x' ); }); it('uses map file for explicit user match', () => { const mapFile = path.join(os.tmpdir(), `scim-base-map-${Date.now()}.json`); fs.writeFileSync( mapFile, JSON.stringify({ alice: { userBase: 'ou=alice-users,dc=ex', groupBase: 'ou=alice-groups,dc=ex', }, '*': { userBase: 'ou=any-users,dc=ex' }, }) ); try { const br = new BaseResolver(cfg({ scim_base_map: mapFile })); expect(br.userBase({ user: 'alice' })).to.equal('ou=alice-users,dc=ex'); expect(br.groupBase({ user: 'alice' })).to.equal('ou=alice-groups,dc=ex'); // Unknown user falls back to wildcard expect(br.userBase({ user: 'other' })).to.equal('ou=any-users,dc=ex'); } finally { fs.unlinkSync(mapFile); } }); describe('header-based base', () => { function reqWithHeader(name: string, value: string): { user?: string } { const headers: Record = { [name.toLowerCase()]: value }; return { get(n: string): string | undefined { return headers[n.toLowerCase()]; }, } as unknown as { user?: string }; } it('honors a header base under the allowed root', () => { const br = new BaseResolver( cfg({ scim_user_base: 'ou=users,dc=example,dc=com', scim_user_base_header: 'x-org-base', scim_base_header_root: 'ou=b2b,dc=example,dc=com', }) ); const req = reqWithHeader( 'x-org-base', 'ou=users,ou=acme,ou=b2b,dc=example,dc=com' ); expect(br.userBase(req)).to.equal( 'ou=users,ou=acme,ou=b2b,dc=example,dc=com' ); }); it('ignores a header base outside the allowed root', () => { const br = new BaseResolver( cfg({ scim_user_base: 'ou=users,dc=example,dc=com', scim_user_base_header: 'x-org-base', scim_base_header_root: 'ou=b2b,dc=example,dc=com', }) ); const req = reqWithHeader('x-org-base', 'ou=evil,dc=other,dc=com'); expect(br.userBase(req)).to.equal('ou=users,dc=example,dc=com'); }); it('ignores the header when no root is configured', () => { const br = new BaseResolver( cfg({ scim_user_base: 'ou=b2b,dc=example,dc=com', scim_user_base_header: 'x-org-base', // no scim_base_header_root → header path disabled }) ); const req = reqWithHeader( 'x-org-base', 'ou=acme,ou=b2b,dc=example,dc=com' ); expect(br.userBase(req)).to.equal('ou=b2b,dc=example,dc=com'); }); it('ignores a header value containing control characters', () => { const br = new BaseResolver( cfg({ scim_user_base: 'ou=users,dc=example,dc=com', scim_user_base_header: 'x-org-base', scim_base_header_root: 'ou=b2b,dc=example,dc=com', }) ); const req = reqWithHeader( 'x-org-base', 'ou=a\u0000x,ou=b2b,dc=example,dc=com' ); expect(br.userBase(req)).to.equal('ou=users,dc=example,dc=com'); }); it('an explicit map entry wins over the header', () => { const mapFile = path.join( os.tmpdir(), `scim-base-map-hdr-${Date.now()}.json` ); fs.writeFileSync( mapFile, JSON.stringify({ alice: { userBase: 'ou=pinned,ou=b2b,dc=example,dc=com' }, }) ); try { const br = new BaseResolver( cfg({ scim_user_base: 'ou=b2b,dc=example,dc=com', scim_user_base_header: 'x-org-base', scim_base_header_root: 'ou=b2b,dc=example,dc=com', scim_base_map: mapFile, }) ); const req = reqWithHeader( 'x-org-base', 'ou=acme,ou=b2b,dc=example,dc=com' ); (req as { user?: string }).user = 'alice'; expect(br.userBase(req)).to.equal('ou=pinned,ou=b2b,dc=example,dc=com'); } finally { fs.unlinkSync(mapFile); } }); it('takes precedence over the template', () => { const br = new BaseResolver( cfg({ scim_user_base: 'ou=b2b,dc=example,dc=com', scim_user_base_template: 'ou={user},dc=example,dc=com', scim_user_base_header: 'x-org-base', scim_base_header_root: 'ou=b2b,dc=example,dc=com', }) ); const req = reqWithHeader( 'x-org-base', 'ou=acme,ou=b2b,dc=example,dc=com' ); (req as { user?: string }).user = 'tenant1'; expect(br.userBase(req)).to.equal('ou=acme,ou=b2b,dc=example,dc=com'); }); }); it('resolution order: map > template > static', () => { const mapFile = path.join( os.tmpdir(), `scim-base-map-order-${Date.now()}.json` ); fs.writeFileSync( mapFile, JSON.stringify({ alice: { userBase: 'ou=from-map,dc=ex' } }) ); try { const br = new BaseResolver( cfg({ scim_user_base: 'ou=from-static,dc=ex', scim_user_base_template: 'ou=from-template-{user},dc=ex', scim_base_map: mapFile, }) ); // alice → map expect(br.userBase({ user: 'alice' })).to.equal('ou=from-map,dc=ex'); // other → template (map has no "*" entry) expect(br.userBase({ user: 'other' })).to.equal( 'ou=from-template-other,dc=ex' ); } finally { fs.unlinkSync(mapFile); } }); }); linagora-ldap-rest-16e557e/test/plugins/scim/filter.test.ts000066400000000000000000000124031522642357000237560ustar00rootroot00000000000000import { expect } from 'chai'; import { scimFilterToLdap } from '../../../src/plugins/scim/filter'; import { DEFAULT_USER_MAPPING } from '../../../src/plugins/scim/mapping'; import { ScimError } from '../../../src/plugins/scim/errors'; describe('SCIM filter parser', () => { const m = DEFAULT_USER_MAPPING; describe('comparison operators', () => { it('translates eq string', () => { const r = scimFilterToLdap('userName eq "alice"', m); expect(r.ldapFilter).to.equal('(uid=alice)'); }); it('translates ne string', () => { const r = scimFilterToLdap('userName ne "alice"', m); expect(r.ldapFilter).to.equal('(!(uid=alice))'); }); it('translates co (contains)', () => { const r = scimFilterToLdap('displayName co "ali"', m); expect(r.ldapFilter).to.equal('(displayName=*ali*)'); }); it('translates sw (starts with)', () => { const r = scimFilterToLdap('displayName sw "Al"', m); expect(r.ldapFilter).to.equal('(displayName=Al*)'); }); it('translates ew (ends with)', () => { const r = scimFilterToLdap('displayName ew "ce"', m); expect(r.ldapFilter).to.equal('(displayName=*ce)'); }); it('translates pr (present)', () => { const r = scimFilterToLdap('displayName pr', m); expect(r.ldapFilter).to.equal('(displayName=*)'); }); it('translates gt / ge / lt / le', () => { expect(scimFilterToLdap('displayName gt "A"', m).ldapFilter).to.match( /^\(&\(displayName>=A\)\(!\(displayName=A\)\)\)$/ ); expect(scimFilterToLdap('displayName ge "A"', m).ldapFilter).to.equal( '(displayName>=A)' ); expect(scimFilterToLdap('displayName lt "Z"', m).ldapFilter).to.match( /^\(&\(displayName<=Z\)\(!\(displayName=Z\)\)\)$/ ); expect(scimFilterToLdap('displayName le "Z"', m).ldapFilter).to.equal( '(displayName<=Z)' ); }); }); describe('logic combinators', () => { it('and combines', () => { const r = scimFilterToLdap('userName eq "alice" and displayName pr', m); expect(r.ldapFilter).to.equal('(&(uid=alice)(displayName=*))'); }); it('or combines', () => { const r = scimFilterToLdap('userName eq "alice" or userName eq "bob"', m); expect(r.ldapFilter).to.equal('(|(uid=alice)(uid=bob))'); }); it('not negates', () => { const r = scimFilterToLdap('not (userName eq "alice")', m); expect(r.ldapFilter).to.equal('(!(uid=alice))'); }); it('parentheses override precedence', () => { const r = scimFilterToLdap( '(userName eq "a" or userName eq "b") and displayName pr', m ); expect(r.ldapFilter).to.equal('(&(|(uid=a)(uid=b))(displayName=*))'); }); }); describe('sub-attribute paths', () => { it('name.familyName → sn', () => { const r = scimFilterToLdap('name.familyName eq "Doe"', m); expect(r.ldapFilter).to.equal('(sn=Doe)'); }); it('emails.value → mail', () => { const r = scimFilterToLdap('emails.value co "@example.com"', m); // emails maps to primary=mail per default mapping expect(r.ldapFilter).to.equal('(mail=*@example.com*)'); }); }); describe('id short-circuit', () => { it('id eq "foo" returns idEquals', () => { const r = scimFilterToLdap('id eq "foo"', m); expect(r.touchesId).to.be.true; expect(r.idEquals).to.equal('foo'); }); }); describe('active pseudo-attribute', () => { it('active eq true → disabled-account filter', () => { const r = scimFilterToLdap('active eq true', m); expect(r.ldapFilter).to.equal('(!(pwdAccountLockedTime=*))'); }); it('active eq false', () => { const r = scimFilterToLdap('active eq false', m); expect(r.ldapFilter).to.equal('(pwdAccountLockedTime=*)'); }); }); describe('security: LDAP injection escaping', () => { it('escapes asterisk', () => { const r = scimFilterToLdap('userName eq "a*b"', m); expect(r.ldapFilter).to.equal('(uid=a\\2ab)'); }); it('escapes parentheses', () => { const r = scimFilterToLdap('userName eq "a(b)c"', m); expect(r.ldapFilter).to.equal('(uid=a\\28b\\29c)'); }); it('escapes backslash', () => { const r = scimFilterToLdap('userName eq "a\\\\b"', m); expect(r.ldapFilter).to.equal('(uid=a\\5cb)'); }); }); describe('errors', () => { it('unknown attribute throws invalidFilter', () => { expect(() => scimFilterToLdap('nosuch eq "x"', m)).to.throw(ScimError); }); it('malformed filter throws', () => { expect(() => scimFilterToLdap('userName eq', m)).to.throw(ScimError); }); it('unterminated string throws', () => { expect(() => scimFilterToLdap('userName eq "unfinished', m)).to.throw( ScimError ); }); }); describe('edge cases', () => { it('empty filter returns objectClass=*', () => { const r = scimFilterToLdap('', m); expect(r.ldapFilter).to.equal('(objectClass=*)'); }); it('whitespace-only filter returns objectClass=*', () => { const r = scimFilterToLdap(' ', m); expect(r.ldapFilter).to.equal('(objectClass=*)'); }); it('null values', () => { const r = scimFilterToLdap('displayName eq null', m); expect(r.ldapFilter).to.equal('(!(displayName=*))'); }); }); }); linagora-ldap-rest-16e557e/test/plugins/scim/mapping.test.ts000066400000000000000000000135021522642357000241250ustar00rootroot00000000000000import { expect } from 'chai'; import { DEFAULT_USER_MAPPING, DEFAULT_GROUP_MAPPING, ldapToScimUser, scimUserToLdap, ldapToScimGroup, scimPathToLdapAttribute, requiredLdapAttributes, type MappingContext, } from '../../../src/plugins/scim/mapping'; const userCtx: MappingContext = { idAttribute: 'rdn', rdnAttribute: 'uid', resourceType: 'User', baseUrl: 'https://example.test', scimPrefix: '/scim/v2', }; const groupCtx: MappingContext = { ...userCtx, rdnAttribute: 'cn', resourceType: 'Group', }; describe('SCIM mapping', () => { describe('ldapToScimUser', () => { it('maps inetOrgPerson attributes to SCIM User', () => { const user = ldapToScimUser( { uid: 'alice', cn: 'Alice Doe', sn: 'Doe', givenName: 'Alice', displayName: 'Alice D.', mail: 'alice@example.com', mailAlternateAddress: ['alice.doe@corp.com', 'ad@corp.com'], createTimestamp: '20250101000000Z', modifyTimestamp: '20250202000000Z', }, DEFAULT_USER_MAPPING, userCtx ); expect(user.id).to.equal('alice'); expect(user.userName).to.equal('alice'); expect(user.displayName).to.equal('Alice D.'); expect(user.name).to.deep.equal({ familyName: 'Doe', givenName: 'Alice', formatted: 'Alice Doe', }); expect(user.emails).to.deep.equal([ { value: 'alice@example.com', primary: true }, { value: 'alice.doe@corp.com' }, { value: 'ad@corp.com' }, ]); expect(user.active).to.be.true; expect(user.meta?.resourceType).to.equal('User'); expect(user.meta?.location).to.equal( 'https://example.test/scim/v2/Users/alice' ); }); it('marks locked accounts as active=false', () => { const user = ldapToScimUser( { uid: 'bob', pwdAccountLockedTime: '20260101000000Z' }, DEFAULT_USER_MAPPING, userCtx ); expect(user.active).to.be.false; }); }); describe('scimUserToLdap', () => { it('converts SCIM User to LDAP attributes', () => { const { rdn, attributes } = scimUserToLdap( { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'alice', name: { familyName: 'Doe', givenName: 'Alice', formatted: 'Alice Doe', }, displayName: 'Alice', emails: [ { value: 'alice@example.com', primary: true }, { value: 'ad@corp.com' }, ], }, DEFAULT_USER_MAPPING, userCtx, ['top', 'inetOrgPerson', 'person'] ); expect(rdn).to.equal('alice'); // Per default mapping, userName → uid, so it is populated here too. expect(attributes.uid).to.equal('alice'); expect(attributes.sn).to.equal('Doe'); expect(attributes.givenName).to.equal('Alice'); expect(attributes.cn).to.equal('Alice Doe'); expect(attributes.mail).to.equal('alice@example.com'); expect(attributes.mailAlternateAddress).to.deep.equal(['ad@corp.com']); expect(attributes.objectClass).to.deep.equal([ 'top', 'inetOrgPerson', 'person', ]); }); it('fills defaults cn and sn when missing', () => { const { attributes } = scimUserToLdap( { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'ghost', }, DEFAULT_USER_MAPPING, userCtx, ['top', 'inetOrgPerson'] ); expect(attributes.cn).to.equal('ghost'); expect(attributes.sn).to.equal('ghost'); }); }); describe('ldapToScimGroup', () => { it('maps groupOfNames to SCIM Group', () => { const g = ldapToScimGroup( { cn: 'admins', member: [ 'uid=alice,ou=users,dc=example,dc=com', 'uid=bob,ou=users,dc=example,dc=com', ], }, DEFAULT_GROUP_MAPPING, groupCtx ); expect(g.id).to.equal('admins'); expect(g.displayName).to.equal('admins'); expect(g.members).to.have.lengthOf(2); expect(g.members?.[0].value).to.equal( 'uid=alice,ou=users,dc=example,dc=com' ); }); it('member resolver translates DN → SCIM ref', () => { const g = ldapToScimGroup( { cn: 'ops', member: ['uid=alice,ou=users,dc=example,dc=com'], }, DEFAULT_GROUP_MAPPING, groupCtx, dn => { const rdnValue = /^uid=([^,]+)/.exec(dn)?.[1]; return rdnValue ? { value: rdnValue, type: 'User' } : undefined; } ); expect(g.members?.[0]).to.deep.equal({ value: 'alice', type: 'User' }); }); }); describe('scimPathToLdapAttribute', () => { it('resolves simple attribute', () => { expect( scimPathToLdapAttribute('userName', DEFAULT_USER_MAPPING) ).to.equal('uid'); }); it('resolves sub-attribute', () => { expect( scimPathToLdapAttribute('name.familyName', DEFAULT_USER_MAPPING) ).to.equal('sn'); }); it('resolves multi-valued primary', () => { expect( scimPathToLdapAttribute('emails.value', DEFAULT_USER_MAPPING) ).to.equal('mail'); }); it('returns undefined for unknown path', () => { expect(scimPathToLdapAttribute('unknown.attr', DEFAULT_USER_MAPPING)).to .be.undefined; }); }); describe('requiredLdapAttributes', () => { it('collects all LDAP attrs used by the mapping', () => { const attrs = requiredLdapAttributes(DEFAULT_USER_MAPPING); expect(attrs).to.include('uid'); expect(attrs).to.include('sn'); expect(attrs).to.include('mail'); expect(attrs).to.include('mailAlternateAddress'); expect(attrs).to.include('entryUUID'); expect(attrs).to.include('createTimestamp'); }); }); }); linagora-ldap-rest-16e557e/test/plugins/scim/patch.test.ts000066400000000000000000000125771522642357000236040ustar00rootroot00000000000000import { expect } from 'chai'; import { patchToModifyRequest, applyPatchToResource, } from '../../../src/plugins/scim/patch'; import { DEFAULT_USER_MAPPING } from '../../../src/plugins/scim/mapping'; import { ScimError } from '../../../src/plugins/scim/errors'; describe('SCIM PATCH applicator', () => { const ctx = { mapping: DEFAULT_USER_MAPPING }; it('add simple attribute → { add: {...} }', async () => { const req = await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [{ op: 'add', path: 'displayName', value: 'Alice' }], }, ctx ); expect(req.add).to.deep.equal({ displayName: 'Alice' }); }); it('replace sub-attribute → { replace: {...} }', async () => { const req = await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [ { op: 'replace', path: 'name.familyName', value: 'Smith' }, ], }, ctx ); expect(req.replace).to.deep.equal({ sn: 'Smith' }); }); it('remove attribute → { delete: {...} }', async () => { const req = await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [{ op: 'remove', path: 'displayName' }], }, ctx ); expect(req.delete).to.deep.equal({ displayName: '' }); }); it('no-path op with value object fans out', async () => { const req = await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [ { op: 'replace', value: { displayName: 'Z', title: 'CEO' } }, ], }, ctx ); expect(req.replace).to.deep.equal({ displayName: 'Z', title: 'CEO' }); }); it('multiple operations merge', async () => { const req = await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [ { op: 'add', path: 'title', value: 'Dev' }, { op: 'replace', path: 'displayName', value: 'Alice' }, { op: 'remove', path: 'nickName' }, ], }, ctx ); expect(req.add).to.deep.equal({ title: 'Dev' }); expect(req.replace).to.deep.equal({ displayName: 'Alice' }); expect(req.delete).to.deep.equal({ displayName: '' }); }); it('member add resolves via resolveMemberRef', async () => { const seen: string[] = []; const req = await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [ { op: 'add', path: 'members', value: [{ value: 'alice' }, { value: 'bob' }], }, ], }, { mapping: DEFAULT_USER_MAPPING, memberAttribute: 'member', resolveMemberRef: async v => { seen.push(v); return `uid=${v},ou=users,dc=example,dc=com`; }, } ); expect(seen).to.deep.equal(['alice', 'bob']); expect(req.add).to.deep.equal({ member: [ 'uid=alice,ou=users,dc=example,dc=com', 'uid=bob,ou=users,dc=example,dc=com', ], }); }); it('member remove via value-path filter', async () => { const req = await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [{ op: 'remove', path: 'members[value eq "alice"]' }], }, { mapping: DEFAULT_USER_MAPPING, resolveMemberRef: async v => `uid=${v},ou=users,dc=example,dc=com`, } ); expect(req.delete).to.deep.equal({ member: ['uid=alice,ou=users,dc=example,dc=com'], }); }); it('unknown path throws invalidPath', async () => { try { await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [{ op: 'add', path: 'nosuch', value: 'x' }], }, ctx ); expect.fail('should have thrown'); } catch (err) { expect(err).to.be.instanceOf(ScimError); expect((err as ScimError).scimType).to.equal('invalidPath'); } }); it('unknown op throws invalidValue', async () => { try { await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], // @ts-expect-error — testing runtime validation Operations: [{ op: 'merge', path: 'displayName', value: 'x' }], }, ctx ); expect.fail('should have thrown'); } catch (err) { expect(err).to.be.instanceOf(ScimError); } }); describe('applyPatchToResource', () => { it('applies add/replace/remove on a plain object', () => { const r = applyPatchToResource( { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'alice', displayName: 'Old', name: { familyName: 'Doe' }, }, { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [ { op: 'replace', path: 'displayName', value: 'New' }, { op: 'add', path: 'name.givenName', value: 'Alice' }, { op: 'remove', path: 'userName' }, ], } ); expect(r.displayName).to.equal('New'); expect((r.name as Record).givenName).to.equal('Alice'); expect(r.userName).to.be.undefined; }); }); }); linagora-ldap-rest-16e557e/test/plugins/scim/scim.bulk.test.ts000066400000000000000000000105341522642357000243630ustar00rootroot00000000000000import { expect } from 'chai'; import supertest from 'supertest'; import Scim from '../../../src/plugins/scim/scim'; import { DM } from '../../../src/bin'; describe('SCIM Bulk (integration)', function () { let server: DM; let plugin: Scim; let userBase: string; let groupBase: string; let savedUserBase: string | undefined; let savedGroupBase: string | undefined; before(async function () { if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_BASE ) { // eslint-disable-next-line no-console console.warn('Skipping SCIM Bulk tests: LDAP env vars missing'); this.skip(); return; } const baseDn = process.env.DM_LDAP_BASE; userBase = `ou=users,${baseDn}`; groupBase = `ou=groups,${baseDn}`; savedUserBase = process.env.DM_SCIM_USER_BASE; savedGroupBase = process.env.DM_SCIM_GROUP_BASE; process.env.DM_SCIM_USER_BASE = userBase; process.env.DM_SCIM_GROUP_BASE = groupBase; server = new DM(); plugin = new Scim(server); await plugin.api(server.app); await server.ready; }); after(() => { if (savedUserBase === undefined) delete process.env.DM_SCIM_USER_BASE; else process.env.DM_SCIM_USER_BASE = savedUserBase; if (savedGroupBase === undefined) delete process.env.DM_SCIM_GROUP_BASE; else process.env.DM_SCIM_GROUP_BASE = savedGroupBase; }); afterEach(async () => { if (!plugin) return; for (const uid of ['bulk-u1', 'bulk-u2']) { try { await plugin.ldap.delete(`uid=${uid},${userBase}`); } catch { /* ignore */ } } for (const cn of ['bulk-g1']) { try { await plugin.ldap.delete(`cn=${cn},${groupBase}`); } catch { /* ignore */ } } }); it('creates a user and a group in one request with bulkId ref', async () => { const res = await supertest(server.app) .post('/scim/v2/Bulk') .set('Content-Type', 'application/scim+json') .send({ 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: 'bulk-u1', name: { familyName: 'One' }, }, }, { method: 'POST', bulkId: 'g1', path: '/Groups', data: { schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], displayName: 'bulk-g1', members: [{ value: 'bulkId:u1' }], }, }, ], }) .expect(200); expect(res.body.Operations).to.have.lengthOf(2); expect(res.body.Operations[0].status).to.equal('201'); expect(res.body.Operations[1].status).to.equal('201'); expect(res.body.Operations[1].location).to.match(/\/Groups\/bulk-g1$/); // Verify the group actually contains the user const group = await supertest(server.app) .get('/scim/v2/Groups/bulk-g1') .expect(200); const found = (group.body.members as { value: string }[] | undefined)?.some( m => m.value === 'bulk-u1' ); expect(found, 'group should contain bulk-u1').to.be.true; }); it('honors failOnErrors', async () => { const res = await supertest(server.app) .post('/scim/v2/Bulk') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:api:messages:2.0:BulkRequest'], failOnErrors: 1, Operations: [ { method: 'POST', path: '/Users', data: { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], // userName missing → triggers invalidValue name: { familyName: 'X' }, }, }, { method: 'POST', path: '/Users', data: { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'bulk-u2', name: { familyName: 'Two' }, }, }, ], }) .expect(200); // With failOnErrors=1, after the first error the second op must not run expect(res.body.Operations).to.have.lengthOf(1); expect(parseInt(res.body.Operations[0].status, 10)).to.be.at.least(400); }); }); linagora-ldap-rest-16e557e/test/plugins/scim/scim.groups.test.ts000066400000000000000000000147701522642357000247530ustar00rootroot00000000000000import { expect } from 'chai'; import supertest from 'supertest'; import Scim from '../../../src/plugins/scim/scim'; import { DM } from '../../../src/bin'; describe('SCIM Groups (integration)', function () { let server: DM; let plugin: Scim; let userBase: string; let groupBase: string; let savedUserBase: string | undefined; let savedGroupBase: string | undefined; before(async function () { if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_BASE ) { // eslint-disable-next-line no-console console.warn('Skipping SCIM Groups tests: LDAP env vars missing'); this.skip(); return; } const baseDn = process.env.DM_LDAP_BASE; userBase = `ou=users,${baseDn}`; groupBase = `ou=groups,${baseDn}`; savedUserBase = process.env.DM_SCIM_USER_BASE; savedGroupBase = process.env.DM_SCIM_GROUP_BASE; process.env.DM_SCIM_USER_BASE = userBase; process.env.DM_SCIM_GROUP_BASE = groupBase; server = new DM(); plugin = new Scim(server); await plugin.api(server.app); await server.ready; try { await plugin.ldap.add(`uid=scim-groupuser,${userBase}`, { objectClass: ['top', 'inetOrgPerson', 'organizationalPerson', 'person'], cn: 'Group User', sn: 'User', uid: 'scim-groupuser', }); } catch { /* may already exist */ } }); after(async () => { if (plugin) { try { await plugin.ldap.delete(`uid=scim-groupuser,${userBase}`); } catch { /* ignore */ } } if (savedUserBase === undefined) delete process.env.DM_SCIM_USER_BASE; else process.env.DM_SCIM_USER_BASE = savedUserBase; if (savedGroupBase === undefined) delete process.env.DM_SCIM_GROUP_BASE; else process.env.DM_SCIM_GROUP_BASE = savedGroupBase; }); afterEach(async () => { if (!plugin) return; for (const id of ['scim-testgroup', 'scim-othergroup']) { try { await plugin.ldap.delete(`cn=${id},${groupBase}`); } catch { /* ignore */ } } }); it('creates a Group', async () => { const res = await supertest(server.app) .post('/scim/v2/Groups') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], displayName: 'scim-testgroup', members: [{ value: 'scim-groupuser' }], }) .expect(201); expect(res.body.id).to.equal('scim-testgroup'); expect(res.body.displayName).to.equal('scim-testgroup'); expect(res.body.members).to.have.lengthOf.at.least(1); }); it('gets a Group by id with SCIM member refs', async () => { await supertest(server.app) .post('/scim/v2/Groups') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], displayName: 'scim-testgroup', members: [{ value: 'scim-groupuser' }], }) .expect(201); const res = await supertest(server.app) .get('/scim/v2/Groups/scim-testgroup') .expect(200); const user = res.body.members?.find( (m: { value: string }) => m.value === 'scim-groupuser' ); expect(user, 'group should include the scim-groupuser member').to.exist; expect(user.type).to.equal('User'); }); it('PATCH adds a member', async () => { await supertest(server.app) .post('/scim/v2/Groups') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], displayName: 'scim-testgroup', }) .expect(201); const res = await supertest(server.app) .patch('/scim/v2/Groups/scim-testgroup') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [ { op: 'add', path: 'members', value: [{ value: 'scim-groupuser' }], }, ], }) .expect(200); const found = (res.body.members as { value: string }[] | undefined)?.some( m => m.value === 'scim-groupuser' ); expect(found).to.be.true; }); it('PATCH removes a member by value filter', async () => { await supertest(server.app) .post('/scim/v2/Groups') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], displayName: 'scim-testgroup', members: [{ value: 'scim-groupuser' }], }) .expect(201); await supertest(server.app) .patch('/scim/v2/Groups/scim-testgroup') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [ { op: 'remove', path: 'members[value eq "scim-groupuser"]' }, ], }) .expect(200); const res = await supertest(server.app) .get('/scim/v2/Groups/scim-testgroup') .expect(200); const hasUser = (res.body.members as { value: string }[] | undefined)?.some( m => m.value === 'scim-groupuser' ); expect(hasUser).to.not.be.true; }); it('filters groups by displayName eq', async () => { await supertest(server.app) .post('/scim/v2/Groups') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], displayName: 'scim-testgroup', }) .expect(201); await supertest(server.app) .post('/scim/v2/Groups') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], displayName: 'scim-othergroup', }) .expect(201); const res = await supertest(server.app) .get( '/scim/v2/Groups?filter=' + encodeURIComponent('displayName eq "scim-testgroup"') ) .expect(200); expect(res.body.totalResults).to.equal(1); expect(res.body.Resources[0].displayName).to.equal('scim-testgroup'); }); it('DELETE removes the Group', async () => { await supertest(server.app) .post('/scim/v2/Groups') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], displayName: 'scim-testgroup', }) .expect(201); await supertest(server.app) .delete('/scim/v2/Groups/scim-testgroup') .expect(204); await supertest(server.app) .get('/scim/v2/Groups/scim-testgroup') .expect(404); }); }); linagora-ldap-rest-16e557e/test/plugins/scim/scim.isolation.test.ts000066400000000000000000000153011522642357000254240ustar00rootroot00000000000000/** * Integration tests for cross-tenant isolation and discovery honesty. * * Each test here verifies a property that the review pass flagged as * insufficiently covered: * - SCIM Group members cannot be injected via arbitrary DNs (tenant escape). * - `sort.supported` is advertised honestly (false) in `ServiceProviderConfig`. * - `attributes=` and `excludedAttributes=` are parsed without erroring. */ import { expect } from 'chai'; import supertest from 'supertest'; import Scim from '../../../src/plugins/scim/scim'; import { DM } from '../../../src/bin'; describe('SCIM cross-tenant isolation (integration)', function () { let server: DM; let plugin: Scim; let userBase: string; let groupBase: string; let savedUserBase: string | undefined; let savedGroupBase: string | undefined; before(async function () { if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_BASE ) { // eslint-disable-next-line no-console console.warn('Skipping SCIM isolation tests: LDAP env vars missing'); this.skip(); return; } const baseDn = process.env.DM_LDAP_BASE; userBase = `ou=users,${baseDn}`; groupBase = `ou=groups,${baseDn}`; savedUserBase = process.env.DM_SCIM_USER_BASE; savedGroupBase = process.env.DM_SCIM_GROUP_BASE; process.env.DM_SCIM_USER_BASE = userBase; process.env.DM_SCIM_GROUP_BASE = groupBase; process.env.DM_GROUP_SCHEMA = ''; server = new DM(); plugin = new Scim(server); await plugin.api(server.app); await server.ready; // Create an in-base user + an OUT-of-base "foreign" entry that must not // be reachable via member references. try { await plugin.ldap.add(`uid=iso-alice,${userBase}`, { objectClass: ['top', 'inetOrgPerson', 'person'], uid: 'iso-alice', cn: 'Alice', sn: 'Alice', }); } catch { /* may already exist */ } try { await plugin.ldap.add(`ou=other-tenant,${baseDn}`, { objectClass: ['top', 'organizationalUnit'], ou: 'other-tenant', }); } catch { /* may already exist */ } try { await plugin.ldap.add(`uid=iso-foreign,ou=other-tenant,${baseDn}`, { objectClass: ['top', 'inetOrgPerson', 'person'], uid: 'iso-foreign', cn: 'Foreign', sn: 'Foreign', }); } catch { /* may already exist */ } }); after(async () => { if (plugin) { for (const dn of [ `uid=iso-alice,${userBase}`, `uid=iso-foreign,ou=other-tenant,${process.env.DM_LDAP_BASE}`, `ou=other-tenant,${process.env.DM_LDAP_BASE}`, ]) { try { await plugin.ldap.delete(dn); } catch { /* ignore */ } } } if (savedUserBase === undefined) delete process.env.DM_SCIM_USER_BASE; else process.env.DM_SCIM_USER_BASE = savedUserBase; if (savedGroupBase === undefined) delete process.env.DM_SCIM_GROUP_BASE; else process.env.DM_SCIM_GROUP_BASE = savedGroupBase; }); afterEach(async () => { if (!plugin) return; try { await plugin.ldap.delete(`cn=iso-group,${groupBase}`); } catch { /* ignore */ } }); it('does not add a member whose DN lies outside the SCIM user base', async () => { const baseDn = process.env.DM_LDAP_BASE; const foreignDn = `uid=iso-foreign,ou=other-tenant,${baseDn}`; const res = await supertest(server.app) .post('/scim/v2/Groups') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], displayName: 'iso-group', // Attempt to escape the tenant base by passing a full DN outside it members: [{ value: foreignDn }], }) .expect(201); // The foreign DN must NOT appear in the created group's members. const values = (res.body.members as { value: string }[] | undefined) || []; for (const m of values) { expect( m.value, 'foreign DN leaked into group members — cross-tenant escape' ).to.not.equal(foreignDn); } }); it('accepts a member DN that lies within the SCIM user base', async () => { const inBaseDn = `uid=iso-alice,${userBase}`; const res = await supertest(server.app) .post('/scim/v2/Groups') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'], displayName: 'iso-group', members: [{ value: inBaseDn }], }) .expect(201); const values = (res.body.members as { value: string; $ref?: string }[] | undefined) || []; const found = values.some(m => m.value === 'iso-alice'); expect(found, 'in-base member should be present in response').to.be.true; }); }); describe('SCIM ServiceProviderConfig honesty (integration)', function () { let server: DM; let plugin: Scim; let savedUserBase: string | undefined; let savedGroupBase: string | undefined; before(async function () { if (!process.env.DM_LDAP_BASE) { this.skip(); return; } const baseDn = process.env.DM_LDAP_BASE; savedUserBase = process.env.DM_SCIM_USER_BASE; savedGroupBase = process.env.DM_SCIM_GROUP_BASE; process.env.DM_SCIM_USER_BASE = `ou=users,${baseDn}`; process.env.DM_SCIM_GROUP_BASE = `ou=groups,${baseDn}`; server = new DM(); plugin = new Scim(server); await plugin.api(server.app); await server.ready; }); after(() => { if (savedUserBase === undefined) delete process.env.DM_SCIM_USER_BASE; else process.env.DM_SCIM_USER_BASE = savedUserBase; if (savedGroupBase === undefined) delete process.env.DM_SCIM_GROUP_BASE; else process.env.DM_SCIM_GROUP_BASE = savedGroupBase; }); it('advertises sort.supported = false', async () => { const res = await supertest(server.app) .get('/scim/v2/ServiceProviderConfig') .expect(200); expect(res.body.sort.supported).to.equal(false); }); it('accepts sortBy / sortOrder query params without error', async () => { // They're parsed for backwards compatibility but not applied. await supertest(server.app) .get('/scim/v2/Users?sortBy=userName&sortOrder=ascending') .expect(200); }); it('accepts attributes / excludedAttributes query params without error', async () => { await supertest(server.app) .get( '/scim/v2/Users?attributes=userName,displayName&excludedAttributes=emails' ) .expect(200); }); it('invalid filter returns a SCIM error envelope', async () => { const res = await supertest(server.app) .get('/scim/v2/Users?filter=' + encodeURIComponent('nosuch pr')) .expect(400); expect(res.body.scimType).to.equal('invalidFilter'); }); }); linagora-ldap-rest-16e557e/test/plugins/scim/scim.users.test.ts000066400000000000000000000226651522642357000245770ustar00rootroot00000000000000import { expect } from 'chai'; import supertest from 'supertest'; import Scim from '../../../src/plugins/scim/scim'; import { DM } from '../../../src/bin'; describe('SCIM Users (integration)', function () { let server: DM; let plugin: Scim; let userBase: string; let savedUserBase: string | undefined; let savedGroupBase: string | undefined; before(async function () { // setup.ts populates DM_LDAP_* env vars via mocha root beforeAll hook if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_BASE ) { // eslint-disable-next-line no-console console.warn('Skipping SCIM integration tests: LDAP env vars missing'); this.skip(); return; } const baseDn = process.env.DM_LDAP_BASE; userBase = `ou=users,${baseDn}`; // Snapshot env before mutating so we can restore in `after`. savedUserBase = process.env.DM_SCIM_USER_BASE; savedGroupBase = process.env.DM_SCIM_GROUP_BASE; process.env.DM_SCIM_USER_BASE = userBase; process.env.DM_SCIM_GROUP_BASE = `ou=groups,${baseDn}`; server = new DM(); plugin = new Scim(server); await plugin.api(server.app); await server.ready; }); after(() => { if (savedUserBase === undefined) delete process.env.DM_SCIM_USER_BASE; else process.env.DM_SCIM_USER_BASE = savedUserBase; if (savedGroupBase === undefined) delete process.env.DM_SCIM_GROUP_BASE; else process.env.DM_SCIM_GROUP_BASE = savedGroupBase; }); afterEach(async () => { if (!plugin) return; for (const id of ['scim-alice', 'scim-bob']) { try { await plugin.ldap.delete(`uid=${id},${userBase}`); } catch { /* ignore */ } } }); describe('ServiceProviderConfig', () => { it('advertises capabilities', async () => { const res = await supertest(server.app) .get('/scim/v2/ServiceProviderConfig') .expect(200); expect(res.headers['content-type']).to.match(/application\/scim\+json/); expect(res.body.schemas[0]).to.equal( 'urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig' ); expect(res.body.patch.supported).to.be.true; expect(res.body.bulk.supported).to.be.true; expect(res.body.filter.supported).to.be.true; }); }); describe('ResourceTypes / Schemas', () => { it('lists ResourceTypes', async () => { const res = await supertest(server.app) .get('/scim/v2/ResourceTypes') .expect(200); expect(res.body.totalResults).to.equal(2); const ids = res.body.Resources.map((r: { id: string }) => r.id); expect(ids).to.have.members(['User', 'Group']); }); it('lists Schemas with User + Group', async () => { const res = await supertest(server.app) .get('/scim/v2/Schemas') .expect(200); expect(res.body.totalResults).to.equal(2); }); }); describe('Users CRUD', () => { it('creates a User via POST', async () => { const res = await supertest(server.app) .post('/scim/v2/Users') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'scim-alice', name: { familyName: 'Doe', givenName: 'Alice' }, displayName: 'Alice D.', emails: [{ value: 'alice@example.com', primary: true }], }) .expect(201); expect(res.body.id).to.equal('scim-alice'); expect(res.body.userName).to.equal('scim-alice'); expect(res.body.meta.resourceType).to.equal('User'); expect(res.body.emails[0].value).to.equal('alice@example.com'); }); it('rejects duplicate User with 409', async () => { await supertest(server.app) .post('/scim/v2/Users') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'scim-alice', name: { familyName: 'Doe' }, }) .expect(201); const res = await supertest(server.app) .post('/scim/v2/Users') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'scim-alice', name: { familyName: 'Doe' }, }) .expect(409); expect(res.body.scimType).to.equal('uniqueness'); }); it('gets a User by id', async () => { await supertest(server.app) .post('/scim/v2/Users') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'scim-alice', name: { familyName: 'Doe' }, }) .expect(201); const res = await supertest(server.app) .get('/scim/v2/Users/scim-alice') .expect(200); expect(res.body.userName).to.equal('scim-alice'); }); it('returns 404 in SCIM envelope for unknown User', async () => { const res = await supertest(server.app) .get('/scim/v2/Users/doesnotexist') .expect(404); expect(res.body.schemas[0]).to.equal( 'urn:ietf:params:scim:api:messages:2.0:Error' ); expect(res.body.status).to.equal('404'); }); it('PATCH replaces displayName', async () => { await supertest(server.app) .post('/scim/v2/Users') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'scim-alice', name: { familyName: 'Doe' }, displayName: 'Old', }) .expect(201); const res = await supertest(server.app) .patch('/scim/v2/Users/scim-alice') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [ { op: 'replace', path: 'displayName', value: 'Alice Doe' }, ], }) .expect(200); expect(res.body.displayName).to.equal('Alice Doe'); }); it('PUT replaces the User', async () => { await supertest(server.app) .post('/scim/v2/Users') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'scim-alice', name: { familyName: 'Doe', givenName: 'Alice' }, displayName: 'Original', }) .expect(201); const res = await supertest(server.app) .put('/scim/v2/Users/scim-alice') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'scim-alice', name: { familyName: 'Smith' }, displayName: 'Replaced', }) .expect(200); expect(res.body.displayName).to.equal('Replaced'); expect(res.body.name.familyName).to.equal('Smith'); }); it('DELETE removes the User', async () => { await supertest(server.app) .post('/scim/v2/Users') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'scim-alice', name: { familyName: 'Doe' }, }) .expect(201); await supertest(server.app) .delete('/scim/v2/Users/scim-alice') .expect(204); await supertest(server.app).get('/scim/v2/Users/scim-alice').expect(404); }); }); describe('Users list & filter', () => { beforeEach(async () => { await supertest(server.app) .post('/scim/v2/Users') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'scim-alice', name: { familyName: 'Doe', givenName: 'Alice' }, displayName: 'Alice', }); await supertest(server.app) .post('/scim/v2/Users') .set('Content-Type', 'application/scim+json') .send({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'scim-bob', name: { familyName: 'Smith', givenName: 'Bob' }, displayName: 'Bob', }); }); it('filters by userName eq', async () => { const res = await supertest(server.app) .get( '/scim/v2/Users?filter=' + encodeURIComponent('userName eq "scim-alice"') ) .expect(200); expect(res.body.totalResults).to.equal(1); expect(res.body.Resources[0].userName).to.equal('scim-alice'); }); it('filters by id eq (short-circuit)', async () => { const res = await supertest(server.app) .get('/scim/v2/Users?filter=' + encodeURIComponent('id eq "scim-bob"')) .expect(200); expect(res.body.totalResults).to.equal(1); expect(res.body.Resources[0].id).to.equal('scim-bob'); }); it('paginates with startIndex & count', async () => { const res = await supertest(server.app) .get('/scim/v2/Users?startIndex=1&count=1') .expect(200); expect(res.body.itemsPerPage).to.equal(1); expect(res.body.startIndex).to.equal(1); expect(res.body.totalResults).to.be.at.least(2); }); it('rejects invalid filter with SCIM envelope', async () => { const res = await supertest(server.app) .get('/scim/v2/Users?filter=' + encodeURIComponent('nosuch eq "x"')) .expect(400); expect(res.body.scimType).to.equal('invalidFilter'); }); }); }); linagora-ldap-rest-16e557e/test/plugins/scim/security.test.ts000066400000000000000000000214501522642357000243420ustar00rootroot00000000000000/** * Security / edge-case regression tests for the SCIM plugin. * * Each test here corresponds to a reviewer finding (Copilot / CodeQL) and * documents the intended behaviour — if any of these regresses, the related * class of vulnerability is back. */ import { expect } from 'chai'; import { patchToModifyRequest, applyPatchToResource, } from '../../../src/plugins/scim/patch'; import { scimFilterToLdap } from '../../../src/plugins/scim/filter'; import { DEFAULT_USER_MAPPING } from '../../../src/plugins/scim/mapping'; import { ScimError } from '../../../src/plugins/scim/errors'; describe('SCIM security hardening', () => { describe('prototype pollution via PATCH paths', () => { it('rejects __proto__ in path', async () => { try { await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [{ op: 'add', path: '__proto__', value: 'polluted' }], }, { mapping: DEFAULT_USER_MAPPING } ); expect.fail('should have thrown'); } catch (err) { expect(err).to.be.instanceOf(ScimError); expect((err as ScimError).scimType).to.equal('invalidPath'); } }); it('rejects constructor in path', async () => { try { await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [{ op: 'add', path: 'constructor', value: 'x' }], }, { mapping: DEFAULT_USER_MAPPING } ); expect.fail('should have thrown'); } catch (err) { expect(err).to.be.instanceOf(ScimError); } }); it('rejects prototype in sub-attribute', async () => { try { await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [{ op: 'add', path: 'name.prototype', value: 'x' }], }, { mapping: DEFAULT_USER_MAPPING } ); expect.fail('should have thrown'); } catch (err) { expect(err).to.be.instanceOf(ScimError); } }); it('does not pollute Object.prototype via implicit-path value object', () => { const before = ({} as { polluted?: string }).polluted; applyPatchToResource( { schemas: [] as string[] } as unknown as Record, { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [ // Try to inject via implicit-path object { op: 'replace', value: { __proto__: { polluted: 'leak' } } as unknown as Record< string, unknown >, }, ], } ); const after = ({} as { polluted?: string }).polluted; expect(before, 'before should be undefined').to.be.undefined; expect(after, 'Object.prototype must remain unmutated').to.be.undefined; }); it('does not pollute Object.prototype via sub-attribute key', async () => { const before = ({} as { polluted?: string }).polluted; try { await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [{ op: 'add', path: 'name.__proto__', value: 'leak' }], }, { mapping: DEFAULT_USER_MAPPING } ); } catch { /* expected */ } const after = ({} as { polluted?: string }).polluted; expect(before).to.be.undefined; expect(after).to.be.undefined; }); }); describe('PATCH path parsing — ReDoS / malformed input', () => { it('rejects a pathological repeated pattern in bounded time', async () => { const malicious = '$.' + '.$'.repeat(200); const start = Date.now(); try { await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [{ op: 'add', path: malicious, value: 'x' }], }, { mapping: DEFAULT_USER_MAPPING } ); } catch { /* expected invalidPath */ } const elapsed = Date.now() - start; // Even a polynomial-backtracking regex would take much longer than // 1 s with 200 repetitions — we expect <100 ms. expect(elapsed, `parse took ${elapsed}ms`).to.be.lessThan(1000); }); it('rejects paths longer than the hard limit', async () => { const oversize = 'a' + '.'.repeat(600); try { await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [{ op: 'add', path: oversize, value: 'x' }], }, { mapping: DEFAULT_USER_MAPPING } ); expect.fail('should have thrown'); } catch (err) { expect(err).to.be.instanceOf(ScimError); } }); it('rejects a mismatched bracket', async () => { try { await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [{ op: 'add', path: 'emails[broken', value: 'x' }], }, { mapping: DEFAULT_USER_MAPPING } ); expect.fail('should have thrown'); } catch (err) { expect(err).to.be.instanceOf(ScimError); } }); }); describe('PATCH coerceValue on complex arrays', () => { it('extracts .value from array of objects shaped like SCIM multi-valued entries', async () => { const req = await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [ { op: 'replace', path: 'emails', value: [ { value: 'a@b.com', primary: true }, { value: 'c@d.com' }, ], }, ], }, { mapping: DEFAULT_USER_MAPPING } ); expect(req.replace).to.deep.equal({ mail: ['a@b.com', 'c@d.com'], }); }); it('rejects an array containing objects without a scalar .value', async () => { try { await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [ { op: 'replace', path: 'emails', value: [{ nested: { deep: 'x' } }], }, ], }, { mapping: DEFAULT_USER_MAPPING } ); expect.fail('should have thrown'); } catch (err) { expect(err).to.be.instanceOf(ScimError); expect((err as ScimError).scimType).to.equal('invalidValue'); } }); }); describe('PATCH bracket filters on non-members paths', () => { it('rejects emails[type eq "work"] with invalidPath', async () => { try { await patchToModifyRequest( { schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], Operations: [ { op: 'replace', path: 'emails[type eq "work"].value', value: 'x@y.com', }, ], }, { mapping: DEFAULT_USER_MAPPING } ); expect.fail('should have thrown'); } catch (err) { expect(err).to.be.instanceOf(ScimError); expect((err as ScimError).scimType).to.equal('invalidPath'); } }); }); describe('Filter — id pseudo-attribute restrictions', () => { it('accepts id eq "..." (short-circuit)', () => { const r = scimFilterToLdap('id eq "alice"', DEFAULT_USER_MAPPING); expect(r.idEquals).to.equal('alice'); }); it('rejects id pr', () => { expect(() => scimFilterToLdap('id pr', DEFAULT_USER_MAPPING)).to.throw( ScimError ); }); it('rejects id co "..."', () => { expect(() => scimFilterToLdap('id co "x"', DEFAULT_USER_MAPPING) ).to.throw(ScimError); }); it('rejects id ne "..." (not in short-circuit form)', () => { expect(() => scimFilterToLdap('id ne "x"', DEFAULT_USER_MAPPING) ).to.throw(ScimError); }); }); describe('Filter — bracket filters rejected', () => { it('rejects emails[value eq "x"]', () => { expect(() => scimFilterToLdap('emails[value eq "x"]', DEFAULT_USER_MAPPING) ).to.throw(ScimError); }); it('rejects emails[type eq "work"]', () => { expect(() => scimFilterToLdap('emails[type eq "work"]', DEFAULT_USER_MAPPING) ).to.throw(ScimError); }); it('bracket-rejection error carries invalidFilter scimType', () => { try { scimFilterToLdap('emails[value eq "x"]', DEFAULT_USER_MAPPING); expect.fail('should have thrown'); } catch (err) { expect((err as ScimError).scimType).to.equal('invalidFilter'); } }); }); }); linagora-ldap-rest-16e557e/test/plugins/static.test.ts000066400000000000000000000055141522642357000230320ustar00rootroot00000000000000import fs from 'fs'; import supertest from 'supertest'; import { DM } from '../../src/bin'; import Static from '../../src/plugins/static'; import { expect } from 'chai'; const dir = './test/__plugins__/static'; let dm: DM; let plugin: Static; let savedLdapBase: string | undefined; describe('static', () => { before(() => { savedLdapBase = process.env.DM_LDAP_BASE; }); after(() => { if (savedLdapBase !== undefined) { process.env.DM_LDAP_BASE = savedLdapBase; } else { delete process.env.DM_LDAP_BASE; } }); describe('path configuration', () => { beforeEach(async () => { process.env.NODE_ENV = 'test'; process.env.DM_STATIC_PATH = dir; process.env.DM_LDAP_BASE = 'dc=example,dc=com'; dm = new DM(); await dm.ready; try { fs.rmSync(dir, { recursive: true }); } catch (e) {} }); afterEach(() => { try { fs.rmSync(dir, { recursive: true }); } catch (e) {} }); it('should fail when directory does not exist', async () => { let err: Error | null = null; try { plugin = new Static(dm); await dm.registerPlugin('static', plugin); expect.fail('Should have failed'); } catch (e) { err = e as Error; } expect(err).to.be.an('error'); expect(err?.message).to.match(/Bad directory/); }); it('should fail when path is not a directory', async () => { let err: Error | null = null; try { fs.writeFileSync(dir, 'Hello'); plugin = new Static(dm); await dm.registerPlugin('static', plugin); expect.fail('Should have failed'); } catch (e) { err = e as Error; } expect(err).to.be.an('error'); expect(err?.message).to.match(/Bad directory/); }); it('should return static content', async () => { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(`${dir}/test.txt`, 'Hello World'); plugin = new Static(dm); await dm.registerPlugin('static', plugin); const request = supertest(dm.app); const res = await request.get('/static/test.txt'); expect(res.status).to.equal(200); expect(res.text).to.equal('Hello World'); }); }); describe('default path', () => { beforeEach(async () => { process.env.NODE_ENV = 'test'; delete process.env.DM_STATIC_PATH; dm = new DM(); await dm.ready; }); it('should use default paths and replace parameters', async () => { plugin = new Static(dm); await dm.registerPlugin('static', plugin); const request = supertest(dm.app); const res = await request.get('/static/schemas/twake/groups.json'); expect(res.status).to.equal(200); expect(res.type).to.equal('application/json'); expect(JSON.stringify(res.body)).to.match(/dc=example/); }); }); }); linagora-ldap-rest-16e557e/test/plugins/twake/000077500000000000000000000000001522642357000213235ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/plugins/twake/appAccountsApi.test.ts000066400000000000000000000577061522642357000256020ustar00rootroot00000000000000import { expect } from 'chai'; import request from 'supertest'; import { DM } from '../../../src/bin'; import AppAccountsApi from '../../../src/plugins/twake/appAccountsApi'; import AppAccountsConsistency from '../../../src/plugins/twake/appAccountsConsistency'; import OnChange from '../../../src/plugins/ldap/onChange'; import AuthToken from '../../../src/plugins/auth/token'; describe('App Accounts API Plugin', function () { const timestamp = Date.now(); const testUser = `testuser-${timestamp}`; // App-account endpoints key on the principal email (the `:user` path param), // not the LDAP uid. const principalEmail = `${testUser}@example.com`; // Generated app-account uids are prefixed from the (sanitized) `:user` value. const appUidPrefix = principalEmail.replace(/[^A-Za-z0-9_-]/g, '_'); let applicativeBase: string; let userBase: string; let testUserDN: string; let testApplicativeDN: string; let dm: DM; let appAccountsApi: AppAccountsApi; const testToken = 'test-token-12345'; beforeEach(async function () { this.timeout(10000); // The global test setup (test/setup.ts) provides an LDAP server — either an // external one (env vars set) or an embedded Docker one whose env vars are // exported in the root beforeAll hook. Skip only if neither is available. if (!process.env.DM_LDAP_BASE) { this.skip(); } dm = new DM(); dm.config.ldap_base = process.env.DM_LDAP_BASE; dm.config.auth_token = [testToken]; await dm.ready; // Initialize bases userBase = `ou=users,${process.env.DM_LDAP_BASE}`; applicativeBase = `ou=applicative,${process.env.DM_LDAP_BASE}`; testUserDN = `uid=${testUser},${userBase}`; testApplicativeDN = `uid=${testUser}@example.com,${applicativeBase}`; // Ensure ou=users exists try { await dm.ldap.add(userBase, { objectClass: ['organizationalUnit', 'top'], ou: 'users', }); } catch (err) { // Ignore if already exists } // Ensure ou=applicative exists try { await dm.ldap.add(applicativeBase, { objectClass: ['organizationalUnit', 'top'], ou: 'applicative', }); } catch (err) { // Ignore if already exists } // Configure and register plugins dm.config.applicative_account_base = applicativeBase; dm.config.mail_attribute = 'mail'; dm.config.max_app_accounts = 5; // Register auth plugin const authToken = new AuthToken(dm); await dm.registerPlugin('authToken', authToken); // Register onChange plugin (dependency) const onChange = new OnChange(dm); await dm.registerPlugin('onLdapChange', onChange); // Register appAccountsConsistency plugin const appAccountsConsistency = new AppAccountsConsistency(dm); await dm.registerPlugin('appAccountsConsistency', appAccountsConsistency); // Register API plugin appAccountsApi = new AppAccountsApi(dm); await dm.registerPlugin('appAccountsApi', appAccountsApi); }); afterEach(async () => { // Clean up test data - delete all possible test entries const testDNs = [testUserDN, testApplicativeDN]; // Also clean up any created app accounts try { const result = await dm.ldap.search( { scope: 'sub', filter: `(uid=${testUser}_*)`, paged: false, }, applicativeBase ); const entries = (result as any).searchEntries || []; for (const entry of entries) { testDNs.push(entry.dn); } } catch (err) { // Ignore } for (const dn of testDNs) { try { await dm.ldap.delete(dn); } catch (err) { // Ignore if doesn't exist } } }); describe('GET /api/v1/users/:user/app-accounts', () => { it('should return 401 without authorization', async () => { const res = await request(dm.app) .get(`/api/v1/users/${principalEmail}/app-accounts`) .expect(401); }); it('should return 404 for non-existent user', async () => { const res = await request(dm.app) .get('/api/v1/users/nonexistent@example.com/app-accounts') .set('Authorization', `Bearer ${testToken}`) .expect(404); expect(res.body.error).to.match(/not found/i); }); it('should return empty array for user without app accounts', async () => { // Create user with mail await dm.ldap.add(testUserDN, { objectClass: 'inetOrgPerson', uid: testUser, cn: 'Test User', sn: 'User', mail: `${testUser}@example.com`, }); // Wait for principal account creation await new Promise(resolve => setTimeout(resolve, 100)); const res = await request(dm.app) .get(`/api/v1/users/${principalEmail}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .expect(200); expect(res.body).to.be.an('array'); expect(res.body).to.have.lengthOf(0); }); it('should list app accounts for user', async () => { // Create user await dm.ldap.add(testUserDN, { objectClass: 'inetOrgPerson', uid: testUser, cn: 'Test User', sn: 'User', mail: `${testUser}@example.com`, }); // Wait for principal account await new Promise(resolve => setTimeout(resolve, 100)); // Create app accounts via API const res1 = await request(dm.app) .post(`/api/v1/users/${principalEmail}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .send({ name: 'My Phone' }) .expect(200); const res2 = await request(dm.app) .post(`/api/v1/users/${principalEmail}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .send({ name: 'My Laptop' }) .expect(200); // List accounts const listRes = await request(dm.app) .get(`/api/v1/users/${principalEmail}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .expect(200); expect(listRes.body).to.be.an('array'); expect(listRes.body).to.have.lengthOf(2); // Check accounts are in the list (order may vary) const uids = listRes.body.map((acc: any) => acc.uid); expect(uids).to.include(res1.body.uid); expect(uids).to.include(res2.body.uid); // Check names match const acc1 = listRes.body.find((a: any) => a.uid === res1.body.uid); const acc2 = listRes.body.find((a: any) => a.uid === res2.body.uid); expect(acc1.name).to.equal('My Phone'); expect(acc2.name).to.equal('My Laptop'); }); }); describe('POST /api/v1/users/:user/app-accounts', () => { it('should return 401 without authorization', async () => { const res = await request(dm.app) .post(`/api/v1/users/${principalEmail}/app-accounts`) .send({ name: 'Test' }) .expect(401); }); it('should return 404 for non-existent user', async () => { const res = await request(dm.app) .post('/api/v1/users/nonexistent@example.com/app-accounts') .set('Authorization', `Bearer ${testToken}`) .send({ name: 'Test' }) .expect(404); expect(res.body.error).to.match(/not found/i); }); it('should create an app account with generated credentials', async () => { // Create user await dm.ldap.add(testUserDN, { objectClass: 'inetOrgPerson', uid: testUser, cn: 'Test User', sn: 'User', mail: `${testUser}@example.com`, }); // Wait for principal account await new Promise(resolve => setTimeout(resolve, 100)); const res = await request(dm.app) .post(`/api/v1/users/${principalEmail}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .send({ name: 'My Device' }) .expect(200); expect(res.body).to.have.property('uid'); expect(res.body).to.have.property('pwd'); expect(res.body).to.have.property('mail'); expect(res.body.uid).to.match(new RegExp(`^${appUidPrefix}_c\\d{8}$`)); expect(res.body.pwd).to.match( /^[\w!@#$%]+-[\w!@#$%]+-[\w!@#$%]+-[\w!@#$%]+-[\w!@#$%]+-[\w!@#$%]+$/ ); expect(res.body.mail).to.equal(`${testUser}@example.com`); // Verify account was created in LDAP const searchRes = await dm.ldap.search( { scope: 'base', paged: false, }, `uid=${res.body.uid},${applicativeBase}` ); const entry = (searchRes as any).searchEntries[0]; expect(entry).to.exist; expect(entry.description).to.equal('My Device'); }); it('should create account without description if not provided', async () => { await dm.ldap.add(testUserDN, { objectClass: 'inetOrgPerson', uid: testUser, cn: 'Test User', sn: 'User', mail: `${testUser}@example.com`, }); await new Promise(resolve => setTimeout(resolve, 100)); const res = await request(dm.app) .post(`/api/v1/users/${principalEmail}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .send({}) .expect(200); expect(res.body.uid).to.match(new RegExp(`^${appUidPrefix}_c\\d{8}$`)); // Verify no description attribute const searchRes = await dm.ldap.search( { scope: 'base', paged: false, }, `uid=${res.body.uid},${applicativeBase}` ); const entry = (searchRes as any).searchEntries[0]; expect(entry.description).to.be.undefined; }); it('should enforce max accounts limit', async function () { this.timeout(10000); // Create a new DM instance with max=2 for this test const dmTest = new DM(); dmTest.config.ldap_base = process.env.DM_LDAP_BASE; dmTest.config.auth_token = [testToken]; dmTest.config.applicative_account_base = applicativeBase; dmTest.config.mail_attribute = 'mail'; dmTest.config.max_app_accounts = 2; // Set limit to 2 await dmTest.ready; // Register plugins const authToken = new AuthToken(dmTest); await dmTest.registerPlugin('authToken', authToken); const onChange = new OnChange(dmTest); await dmTest.registerPlugin('onLdapChange', onChange); const appAccountsConsistency = new AppAccountsConsistency(dmTest); await dmTest.registerPlugin( 'appAccountsConsistency', appAccountsConsistency ); const testAppAccountsApi = new AppAccountsApi(dmTest); await dmTest.registerPlugin('appAccountsApi', testAppAccountsApi); // Create user await dmTest.ldap.add(testUserDN, { objectClass: 'inetOrgPerson', uid: testUser, cn: 'Test User', sn: 'User', mail: `${testUser}@example.com`, }); await new Promise(resolve => setTimeout(resolve, 100)); // Create 2 accounts (should succeed) await request(dmTest.app) .post(`/api/v1/users/${principalEmail}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .send({ name: 'Device 1' }) .expect(200); await request(dmTest.app) .post(`/api/v1/users/${principalEmail}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .send({ name: 'Device 2' }) .expect(200); // Third should fail const res = await request(dmTest.app) .post(`/api/v1/users/${principalEmail}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .send({ name: 'Device 3' }) .expect(400); expect(res.body.error).to.match(/Maximum number of accounts/i); }); }); describe('DELETE /api/v1/users/:user/app-accounts/:uid', () => { it('should return 401 without authorization', async () => { const res = await request(dm.app) .delete( `/api/v1/users/${principalEmail}/app-accounts/${testUser}_c12345678` ) .expect(401); }); it('should return 403 when the account belongs to a different principal', async () => { // Create the user and one app account owned by its principal mail await dm.ldap.add(testUserDN, { objectClass: 'inetOrgPerson', uid: testUser, cn: 'Test User', sn: 'User', mail: `${testUser}@example.com`, }); await new Promise(resolve => setTimeout(resolve, 100)); const createRes = await request(dm.app) .post(`/api/v1/users/${principalEmail}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .send({ name: 'My Device' }) .expect(200); const uid = createRes.body.uid; // A different principal must not be able to delete it const res = await request(dm.app) .delete(`/api/v1/users/intruder@example.com/app-accounts/${uid}`) .set('Authorization', `Bearer ${testToken}`) .expect(403); expect(res.body.error).to.match(/does not belong/i); // ...and the account must still exist const survivor = await dm.ldap.search( { scope: 'base', paged: false }, `uid=${uid},${applicativeBase}` ); expect((survivor as any).searchEntries).to.have.lengthOf(1); }); it('should delete an app account', async () => { // Create user await dm.ldap.add(testUserDN, { objectClass: 'inetOrgPerson', uid: testUser, cn: 'Test User', sn: 'User', mail: `${testUser}@example.com`, }); await new Promise(resolve => setTimeout(resolve, 100)); // Create app account const createRes = await request(dm.app) .post(`/api/v1/users/${principalEmail}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .send({ name: 'My Device' }) .expect(200); const uid = createRes.body.uid; // Verify account exists let searchRes = await dm.ldap.search( { scope: 'base', paged: false, }, `uid=${uid},${applicativeBase}` ); expect((searchRes as any).searchEntries).to.have.lengthOf(1); // Delete account const deleteRes = await request(dm.app) .delete(`/api/v1/users/${principalEmail}/app-accounts/${uid}`) .set('Authorization', `Bearer ${testToken}`) .expect(200); expect(deleteRes.body.uid).to.equal(uid); // Verify account was deleted try { await dm.ldap.search( { scope: 'base', paged: false, }, `uid=${uid},${applicativeBase}` ); expect.fail('Account should have been deleted'); } catch (err: any) { expect(err.message || err.code).to.match(/No such object|0x20/i); } }); it('should be idempotent (deleting non-existent account succeeds)', async () => { const res = await request(dm.app) .delete( `/api/v1/users/${principalEmail}/app-accounts/${testUser}_c99999999` ) .set('Authorization', `Bearer ${testToken}`) .expect(200); expect(res.body.uid).to.equal(`${testUser}_c99999999`); }); // Regression test for issue #84: deleting ONE app account must not cascade // into deleting the user's other app accounts (or the principal entry). it('should NOT cascade-delete the other app accounts (issue #84)', async function () { this.timeout(15000); await dm.ldap.add(testUserDN, { objectClass: 'inetOrgPerson', uid: testUser, cn: 'Test User', sn: 'User', mail: `${testUser}@example.com`, }); // Let the consistency plugin create the principal applicative account await new Promise(resolve => setTimeout(resolve, 300)); // Create two app accounts through the real API const created1 = await request(dm.app) .post(`/api/v1/users/${principalEmail}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .send({ name: 'Phone' }) .expect(200); const created2 = await request(dm.app) .post(`/api/v1/users/${principalEmail}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .send({ name: 'Laptop' }) .expect(200); const uid1 = created1.body.uid as string; const uid2 = created2.body.uid as string; // Delete ONLY the first one await request(dm.app) .delete(`/api/v1/users/${principalEmail}/app-accounts/${uid1}`) .set('Authorization', `Bearer ${testToken}`) .expect(200); // Give the async onLdapMailChange hook time to (wrongly) cascade await new Promise(resolve => setTimeout(resolve, 500)); // The deleted account is gone... try { await dm.ldap.search( { scope: 'base', paged: false }, `uid=${uid1},${applicativeBase}` ); expect.fail(`${uid1} should have been deleted`); } catch (err: any) { expect(err.message || err.code).to.match(/No such object|0x20/i); } // ...but the second app account and the principal entry MUST survive. const survivor = await dm.ldap.search( { scope: 'base', paged: false }, `uid=${uid2},${applicativeBase}` ); expect( (survivor as any).searchEntries, `${uid2} must survive a single-account delete (no cascade)` ).to.have.lengthOf(1); const principal = await dm.ldap.search( { scope: 'base', paged: false }, `uid=${testUser}@example.com,${applicativeBase}` ); expect( (principal as any).searchEntries, 'principal applicative account must survive a single-account delete' ).to.have.lengthOf(1); }); }); // Regression: two users sharing the SAME uid under different subtrees, with // distinct mails. App-account operations key on the unique mail, so they must // resolve to the matching principal and never cross-contaminate. describe('uid collisions across subtrees (regression)', () => { const sharedUid = `collide-${timestamp}`; const mailA = `${sharedUid}-a@example.com`; const mailB = `${sharedUid}-b@example.com`; let ouA: string; let ouB: string; let dnA: string; let dnB: string; beforeEach(async function () { if (!process.env.DM_LDAP_BASE) this.skip(); ouA = `ou=orga-${timestamp},${process.env.DM_LDAP_BASE}`; ouB = `ou=orgb-${timestamp},${process.env.DM_LDAP_BASE}`; dnA = `uid=${sharedUid},${ouA}`; dnB = `uid=${sharedUid},${ouB}`; for (const ou of [ouA, ouB]) { try { await dm.ldap.add(ou, { objectClass: ['organizationalUnit', 'top'], ou: ou.split(',')[0].replace('ou=', ''), }); } catch (err) { // Ignore if already exists } } await dm.ldap.add(dnA, { objectClass: 'inetOrgPerson', uid: sharedUid, cn: 'Collide A', sn: 'A', mail: mailA, }); await dm.ldap.add(dnB, { objectClass: 'inetOrgPerson', uid: sharedUid, cn: 'Collide B', sn: 'B', mail: mailB, }); // Let the consistency plugin create both principal accounts await new Promise(resolve => setTimeout(resolve, 200)); }); afterEach(async () => { try { const result = await dm.ldap.search( { scope: 'sub', filter: `(|(mail=${mailA})(mail=${mailB})(uid=${sharedUid}_*))`, paged: false, }, applicativeBase ); for (const entry of (result as any).searchEntries || []) { try { await dm.ldap.delete(entry.dn); } catch (e) { // Ignore } } } catch (e) { // Ignore } for (const dn of [dnA, dnB, ouA, ouB]) { try { await dm.ldap.delete(dn); } catch (e) { // Ignore } } }); it('attaches each app account to the matching principal, not the same-uid user', async () => { const a = await request(dm.app) .post(`/api/v1/users/${mailA}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .send({ name: 'A device' }) .expect(200); const b = await request(dm.app) .post(`/api/v1/users/${mailB}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .send({ name: 'B device' }) .expect(200); // Each app account belongs to its own principal mail expect(a.body.mail).to.equal(mailA); expect(b.body.mail).to.equal(mailB); // Listing by one mail never surfaces the other principal's account const listA = await request(dm.app) .get(`/api/v1/users/${mailA}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .expect(200); const uidsA = listA.body.map((acc: any) => acc.uid); expect(uidsA).to.include(a.body.uid); expect(uidsA).to.not.include(b.body.uid); const listB = await request(dm.app) .get(`/api/v1/users/${mailB}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .expect(200); const uidsB = listB.body.map((acc: any) => acc.uid); expect(uidsB).to.include(b.body.uid); expect(uidsB).to.not.include(a.body.uid); // The same-uid principal cannot delete the other's account await request(dm.app) .delete(`/api/v1/users/${mailA}/app-accounts/${b.body.uid}`) .set('Authorization', `Bearer ${testToken}`) .expect(403); }); }); // Legacy opt-in: app_accounts_user_attribute='uid' restores the pre-#89 // contract where `:user` is the LDAP uid and app-uids stay `_c`. describe('legacy uid key mode (app_accounts_user_attribute=uid)', () => { let dmUid: DM; beforeEach(async function () { this.timeout(10000); if (!process.env.DM_LDAP_BASE) this.skip(); dmUid = new DM(); dmUid.config.ldap_base = process.env.DM_LDAP_BASE; dmUid.config.auth_token = [testToken]; dmUid.config.applicative_account_base = applicativeBase; dmUid.config.mail_attribute = 'mail'; dmUid.config.app_accounts_user_attribute = 'uid'; await dmUid.ready; await dmUid.registerPlugin('authToken', new AuthToken(dmUid)); await dmUid.registerPlugin('onLdapChange', new OnChange(dmUid)); await dmUid.registerPlugin( 'appAccountsConsistency', new AppAccountsConsistency(dmUid) ); await dmUid.registerPlugin('appAccountsApi', new AppAccountsApi(dmUid)); await dmUid.ldap.add(testUserDN, { objectClass: 'inetOrgPerson', uid: testUser, cn: 'Test User', sn: 'User', mail: `${testUser}@example.com`, }); await new Promise(resolve => setTimeout(resolve, 200)); }); it('resolves :user by uid and keeps the _c format', async () => { // The mail is NOT accepted as :user in uid mode. await request(dmUid.app) .post(`/api/v1/users/${principalEmail}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .send({ name: 'Mail device' }) .expect(404); // The uid is. const created = await request(dmUid.app) .post(`/api/v1/users/${testUser}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .send({ name: 'My Device' }) .expect(200); expect(created.body.uid).to.match(new RegExp(`^${testUser}_c\\d{8}$`)); expect(created.body.mail).to.equal(`${testUser}@example.com`); // Listing and deleting work through the uid as well. const list = await request(dmUid.app) .get(`/api/v1/users/${testUser}/app-accounts`) .set('Authorization', `Bearer ${testToken}`) .expect(200); expect(list.body.map((a: any) => a.uid)).to.include(created.body.uid); await request(dmUid.app) .delete(`/api/v1/users/${testUser}/app-accounts/${created.body.uid}`) .set('Authorization', `Bearer ${testToken}`) .expect(200); }); }); describe('Configuration', () => { it('should throw error if applicative_account_base is not configured', () => { const dmTest = new DM(); dmTest.config.applicative_account_base = undefined; expect(() => new AppAccountsApi(dmTest)).to.throw( /applicative_account_base configuration is required/ ); }); }); }); linagora-ldap-rest-16e557e/test/plugins/twake/appAccountsConsistency.test.ts000066400000000000000000000573411522642357000273650ustar00rootroot00000000000000import { DM } from '../../../src/bin'; import AppAccountsConsistency from '../../../src/plugins/twake/appAccountsConsistency'; import OnChange from '../../../src/plugins/ldap/onChange'; import { expect } from 'chai'; describe('App Accounts Consistency Plugin', function () { let testCounter = 0; let timestamp: number; let applicativeBase: string; let userBase: string; let testUserDN: string; let testApplicativeDN: string; let dm: DM; let appAccountsConsistency: AppAccountsConsistency; beforeEach(async function () { this.timeout(10000); // The global test setup (test/setup.ts) provides an LDAP server — either an // external one (env vars set) or an embedded Docker one whose env vars are // exported in the root beforeAll hook. Skip only if neither is available. if (!process.env.DM_LDAP_BASE) { this.skip(); } // Generate unique timestamp for each test to avoid interference timestamp = Date.now() + testCounter++; dm = new DM(); dm.config.ldap_base = process.env.DM_LDAP_BASE; await dm.ready; // Initialize bases userBase = `ou=users,${process.env.DM_LDAP_BASE}`; // Use environment variable if set, otherwise fallback to ou=applicative applicativeBase = process.env.DM_APPLICATIVE_ACCOUNT_BASE || `ou=applicative,${process.env.DM_LDAP_BASE}`; testUserDN = `uid=testuser-${timestamp},${userBase}`; testApplicativeDN = `uid=testuser-${timestamp}@example.com,${applicativeBase}`; // Ensure ou=users exists try { await dm.ldap.add(userBase, { objectClass: ['organizationalUnit', 'top'], ou: 'users', }); } catch (err) { // Ignore if already exists } // Ensure applicative base exists try { // Extract ou from DN (e.g., "ou=appaccounts" from "ou=appaccounts,o=gov,c=mu") const ouMatch = applicativeBase.match(/^ou=([^,]+)/); const ouValue = ouMatch ? ouMatch[1] : 'applicative'; await dm.ldap.add(applicativeBase, { objectClass: ['organizationalUnit', 'top'], ou: ouValue, }); } catch (err) { // Ignore if already exists } // Configure and register plugins dm.config.applicative_account_base = applicativeBase; dm.config.mail_attribute = 'mail'; // Set operational attributes - use default list from config/args.ts // This is needed because test environment doesn't load config from env/cli if (!dm.config.ldap_operational_attribute) { dm.config.ldap_operational_attribute = [ 'dn', 'controls', 'structuralObjectClass', 'entryUUID', 'entryDN', 'subschemaSubentry', 'modifyTimestamp', 'modifiersName', 'createTimestamp', 'creatorsName', 'userPassword', ]; } // Register onChange plugin (dependency) const onChange = new OnChange(dm); await dm.registerPlugin('onLdapChange', onChange); appAccountsConsistency = new AppAccountsConsistency(dm); await dm.registerPlugin('appAccountsConsistency', appAccountsConsistency); }); afterEach(async () => { // Clean up test data - delete all possible test entries const testDNs = [ testUserDN, testApplicativeDN, `uid=testuser2-${timestamp},${userBase}`, `uid=testuser2-${timestamp},${applicativeBase}`, // Cleanup for mail change tests `uid=newemail-${timestamp}@example.com,${applicativeBase}`, `uid=testuser-${timestamp}_c12345678,${applicativeBase}`, `uid=testuser-${timestamp}_c87654321,${applicativeBase}`, `uid=testuser-${timestamp}_c11111111,${applicativeBase}`, `uid=testuser-${timestamp}_c22222222,${applicativeBase}`, `uid=testuser-${timestamp}_c33333333,${applicativeBase}`, ]; for (const dn of testDNs) { try { await dm.ldap.delete(dn); } catch (err) { // Ignore if doesn't exist } } }); describe('User creation with mail', () => { it('should create applicative account when user with mail is added', async () => { // Create user with mail attribute const userAttrs = { objectClass: 'inetOrgPerson', uid: `testuser-${timestamp}`, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, }; await dm.ldap.add(testUserDN, userAttrs); // Wait a bit for hook to execute await new Promise(resolve => setTimeout(resolve, 100)); // Verify applicative account was created const result = await dm.ldap.search( { scope: 'base', paged: false, }, testApplicativeDN ); expect((result as any).searchEntries).to.have.lengthOf(1); const entry = (result as any).searchEntries[0]; expect(entry.uid).to.equal(`testuser-${timestamp}@example.com`); expect(entry.mail).to.equal(`testuser-${timestamp}@example.com`); expect(entry.cn).to.equal('Test User'); }); it('should be idempotent when creating applicative account multiple times', async () => { // Create user with mail const userAttrs = { objectClass: 'inetOrgPerson', uid: `testuser-${timestamp}`, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, }; await dm.ldap.add(testUserDN, userAttrs); await new Promise(resolve => setTimeout(resolve, 100)); // Trigger creation again by modifying mail to same value await dm.ldap.modify(testUserDN, { replace: { mail: `testuser-${timestamp}@example.com` }, }); await new Promise(resolve => setTimeout(resolve, 100)); // Should still have only one applicative account const result = await dm.ldap.search( { scope: 'base', paged: false, }, testApplicativeDN ); expect((result as any).searchEntries).to.have.lengthOf(1); }); it('should handle gracefully when user is deleted before account creation completes', async () => { // This tests the race condition handling // Create user const userAttrs = { objectClass: 'inetOrgPerson', uid: `testuser-${timestamp}`, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, }; await dm.ldap.add(testUserDN, userAttrs); // Immediately delete user (race condition) await dm.ldap.delete(testUserDN); // Wait for hooks to execute await new Promise(resolve => setTimeout(resolve, 200)); // Verify no errors were thrown and account may or may not exist // (this is acceptable - the important thing is no crash) }); it('should not create applicative account when user without mail is added', async () => { const testUserDN2 = `uid=testuser2-${timestamp},${userBase}`; // Create user without mail attribute const userAttrs = { objectClass: 'inetOrgPerson', uid: `testuser2-${timestamp}`, cn: 'Test User 2', sn: 'User', }; await dm.ldap.add(testUserDN2, userAttrs); // Wait a bit for hook to execute await new Promise(resolve => setTimeout(resolve, 100)); // Verify no applicative account was created const testApplicativeDN2 = `uid=testuser2-${timestamp},${applicativeBase}`; try { const result = await dm.ldap.search( { scope: 'base', paged: false, }, testApplicativeDN2 ); // Should not reach here if no entry exists const entries = (result as any).searchEntries || []; expect(entries).to.have.lengthOf(0); } catch (err: any) { // NoSuchObjectError is expected - the applicative account doesn't exist expect(err.message || err.code).to.match(/No such object|0x20/i); } // Cleanup await dm.ldap.delete(testUserDN2); }); }); describe('User deletion', () => { it('should delete applicative account when user is deleted', async () => { // Create user with mail attribute const userAttrs = { objectClass: 'inetOrgPerson', uid: `testuser-${timestamp}`, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, }; await dm.ldap.add(testUserDN, userAttrs); // Wait for applicative account creation await new Promise(resolve => setTimeout(resolve, 100)); // Verify applicative account exists let result = await dm.ldap.search( { scope: 'base', paged: false, }, testApplicativeDN ); expect((result as any).searchEntries).to.have.lengthOf(1); // Delete user await dm.ldap.delete(testUserDN); // Wait for hook to execute await new Promise(resolve => setTimeout(resolve, 100)); // Verify applicative account was deleted try { const result = await dm.ldap.search( { scope: 'base', paged: false, }, testApplicativeDN ); // Should not reach here if entry was deleted const entries = (result as any).searchEntries || []; expect(entries).to.have.lengthOf(0); } catch (err: any) { // NoSuchObjectError is expected - the applicative account was deleted expect(err.message || err.code).to.match(/No such object|0x20/i); } }); it('should delete multiple applicative accounts when user is deleted', async function () { this.timeout(10000); // Create user const userAttrs = { objectClass: 'inetOrgPerson', uid: `testuser-${timestamp}`, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, userPassword: 'P@ssw0rd!123', }; await dm.ldap.add(testUserDN, userAttrs); await new Promise(resolve => setTimeout(resolve, 100)); // Create multiple app accounts const appAccount1DN = `uid=testuser-${timestamp}_c11111111,${applicativeBase}`; const appAccount2DN = `uid=testuser-${timestamp}_c22222222,${applicativeBase}`; const appAccount3DN = `uid=testuser-${timestamp}_c33333333,${applicativeBase}`; await dm.ldap.add(appAccount1DN, { objectClass: 'inetOrgPerson', uid: `testuser-${timestamp}_c11111111`, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, userPassword: 'A1b2@-C3d4$-E5f6!-G7h8#-J9k0%-L1m2@', }); await dm.ldap.add(appAccount2DN, { objectClass: 'inetOrgPerson', uid: `testuser-${timestamp}_c22222222`, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, userPassword: 'M3n4!-P5q6@-R7s8#-T9u0$-V1w2%-X3y4@', }); await dm.ldap.add(appAccount3DN, { objectClass: 'inetOrgPerson', uid: `testuser-${timestamp}_c33333333`, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, userPassword: 'Z1a2!-B3c4@-D5e6#-F7g8$-H9i0%-J1k2@', }); // Delete user (should trigger deletion of all accounts) await dm.ldap.delete(testUserDN); await new Promise(resolve => setTimeout(resolve, 300)); // Verify all accounts were deleted const accountsToCheck = [ testApplicativeDN, appAccount1DN, appAccount2DN, appAccount3DN, ]; for (const dn of accountsToCheck) { try { await dm.ldap.search( { scope: 'base', paged: false, }, dn ); expect.fail(`Account ${dn} should have been deleted`); } catch (err: any) { expect(err.message || err.code).to.match(/No such object|0x20/i); } } }); it('should handle deletion when user has no applicative account', async () => { // Create user without mail const testUserDN2 = `uid=testuser2-${timestamp},${userBase}`; const userAttrs = { objectClass: 'inetOrgPerson', uid: `testuser2-${timestamp}`, cn: 'Test User 2', sn: 'User', }; await dm.ldap.add(testUserDN2, userAttrs); // Delete user await dm.ldap.delete(testUserDN2); // Wait for hook to execute (should not error) await new Promise(resolve => setTimeout(resolve, 100)); // Test passes if no error was thrown }); it('should be idempotent when deleting already deleted accounts', async () => { // Create user const userAttrs = { objectClass: 'inetOrgPerson', uid: `testuser-${timestamp}`, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, }; await dm.ldap.add(testUserDN, userAttrs); await new Promise(resolve => setTimeout(resolve, 100)); // Manually delete applicative account first await dm.ldap.delete(testApplicativeDN); // Then delete user (should not error even though account already deleted) await dm.ldap.delete(testUserDN); await new Promise(resolve => setTimeout(resolve, 100)); // Test passes if no error was thrown }); }); describe('Mail change', () => { it('should update applicative account when user mail changes', async () => { // Create user with mail const userAttrs = { objectClass: 'inetOrgPerson', uid: `testuser-${timestamp}`, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, }; await dm.ldap.add(testUserDN, userAttrs); // Wait for applicative account creation await new Promise(resolve => setTimeout(resolve, 100)); // Verify applicative account exists let result = await dm.ldap.search( { scope: 'base', paged: false, }, testApplicativeDN ); expect((result as any).searchEntries).to.have.lengthOf(1); // Change user's mail await dm.ldap.modify(testUserDN, { replace: { mail: `newemail-${timestamp}@example.com` }, }); // Wait for applicative account update (hooks are async) await new Promise(resolve => setTimeout(resolve, 300)); // Verify old applicative account is deleted try { await dm.ldap.search( { scope: 'base', paged: false, }, testApplicativeDN ); expect.fail('Old applicative account should have been deleted'); } catch (err: any) { expect(err.message || err.code).to.match(/No such object|0x20/i); } // Verify new applicative account exists const newApplicativeDN = `uid=newemail-${timestamp}@example.com,${applicativeBase}`; result = await dm.ldap.search( { scope: 'base', paged: false, }, newApplicativeDN ); expect((result as any).searchEntries).to.have.lengthOf(1); const entry = (result as any).searchEntries[0]; expect(entry.uid).to.equal(`newemail-${timestamp}@example.com`); expect(entry.mail).to.equal(`newemail-${timestamp}@example.com`); // Cleanup await dm.ldap.delete(newApplicativeDN); }); it('should handle mail change when no applicative accounts exist', async () => { // Create user const userAttrs = { objectClass: 'inetOrgPerson', uid: `testuser-${timestamp}`, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, }; await dm.ldap.add(testUserDN, userAttrs); await new Promise(resolve => setTimeout(resolve, 100)); // Manually delete applicative account await dm.ldap.delete(testApplicativeDN); // Change mail (should handle gracefully with no accounts to update) await dm.ldap.modify(testUserDN, { replace: { mail: `newemail-${timestamp}@example.com` }, }); await new Promise(resolve => setTimeout(resolve, 300)); // Verify no new account was created (updateApplicativeAccount returns when no accounts found) const newApplicativeDN = `uid=newemail-${timestamp}@example.com,${applicativeBase}`; try { await dm.ldap.search( { scope: 'base', paged: false, }, newApplicativeDN ); expect.fail( 'No account should have been created when updating with no existing accounts' ); } catch (err: any) { // NoSuchObjectError is expected - no account was created expect(err.message || err.code).to.match(/No such object|0x20/i); } }); it('should update all app accounts when user mail changes', async function () { this.timeout(15000); // Create user with mail const userAttrs = { objectClass: 'inetOrgPerson', uid: `testuser-${timestamp}`, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, userPassword: 'P@ssw0rd!123', }; await dm.ldap.add(testUserDN, userAttrs); // Wait for principal account creation with retry const principalDN = `uid=testuser-${timestamp}@example.com,${applicativeBase}`; let principalCreated = false; for (let i = 0; i < 10 && !principalCreated; i++) { await new Promise(resolve => setTimeout(resolve, 200)); try { const checkResult = await dm.ldap.search( { scope: 'base', paged: false }, principalDN ); if ((checkResult as any).searchEntries?.length > 0) { principalCreated = true; } } catch (err) { // Not found yet, continue waiting } } expect(principalCreated, 'Principal account should have been created').to .be.true; // Create app accounts (simulating API creation) const appAccount1DN = `uid=testuser-${timestamp}_c12345678,${applicativeBase}`; const appAccount2DN = `uid=testuser-${timestamp}_c87654321,${applicativeBase}`; await dm.ldap.add(appAccount1DN, { objectClass: 'inetOrgPerson', uid: `testuser-${timestamp}_c12345678`, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, userPassword: 'A1b2@-C3d4$-E5f6!-G7h8#-J9k0%-L1m2@', description: 'My Phone', }); await dm.ldap.add(appAccount2DN, { objectClass: 'inetOrgPerson', uid: `testuser-${timestamp}_c87654321`, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, userPassword: 'M3n4!-P5q6@-R7s8#-T9u0$-V1w2%-X3y4@', description: 'My Laptop', }); // Change user's mail await dm.ldap.modify(testUserDN, { replace: { mail: `newemail-${timestamp}@example.com` }, }); // Wait for principal account rename with retry const newPrincipalDN = `uid=newemail-${timestamp}@example.com,${applicativeBase}`; let principalRenamed = false; for (let i = 0; i < 15 && !principalRenamed; i++) { await new Promise(resolve => setTimeout(resolve, 200)); try { const checkResult = await dm.ldap.search( { scope: 'base', paged: false }, newPrincipalDN ); if ((checkResult as any).searchEntries?.length > 0) { principalRenamed = true; } } catch (err) { // Not renamed yet, continue waiting } } expect(principalRenamed, 'Principal account should have been renamed').to .be.true; // Verify principal account changed uid let result = await dm.ldap.search( { scope: 'base', paged: false, }, newPrincipalDN ); expect((result as any).searchEntries).to.have.lengthOf(1); let entry = (result as any).searchEntries[0]; expect(entry.uid).to.equal(`newemail-${timestamp}@example.com`); expect(entry.mail).to.equal(`newemail-${timestamp}@example.com`); // Verify app accounts kept their uid but changed mail result = await dm.ldap.search( { scope: 'base', paged: false, }, appAccount1DN ); expect((result as any).searchEntries).to.have.lengthOf(1); entry = (result as any).searchEntries[0]; expect(entry.uid).to.equal(`testuser-${timestamp}_c12345678`); // UID unchanged expect(entry.mail).to.equal(`newemail-${timestamp}@example.com`); // Mail updated expect(entry.description).to.equal('My Phone'); // Description preserved result = await dm.ldap.search( { scope: 'base', paged: false, }, appAccount2DN ); expect((result as any).searchEntries).to.have.lengthOf(1); entry = (result as any).searchEntries[0]; expect(entry.uid).to.equal(`testuser-${timestamp}_c87654321`); // UID unchanged expect(entry.mail).to.equal(`newemail-${timestamp}@example.com`); // Mail updated expect(entry.description).to.equal('My Laptop'); // Description preserved // Cleanup - ignore errors if already deleted try { await dm.ldap.delete(newPrincipalDN); } catch (err) { // Ignore } try { await dm.ldap.delete(appAccount1DN); } catch (err) { // Ignore } try { await dm.ldap.delete(appAccount2DN); } catch (err) { // Ignore } }); }); describe('Re-entrance guard', () => { it('should ignore mail-change events originating in the applicative branch (no cascade)', async function () { this.timeout(10000); // Create user → principal applicative account is auto-created await dm.ldap.add(testUserDN, { objectClass: 'inetOrgPerson', uid: `testuser-${timestamp}`, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, }); await new Promise(resolve => setTimeout(resolve, 200)); // Create two app accounts directly, as the API plugin would const appAccount1DN = `uid=testuser-${timestamp}_c11111111,${applicativeBase}`; const appAccount2DN = `uid=testuser-${timestamp}_c22222222,${applicativeBase}`; const appAccounts: [string, string][] = [ [appAccount1DN, `testuser-${timestamp}_c11111111`], [appAccount2DN, `testuser-${timestamp}_c22222222`], ]; for (const [dn, uid] of appAccounts) { await dm.ldap.add(dn, { objectClass: 'inetOrgPerson', uid, cn: 'Test User', sn: 'User', mail: `testuser-${timestamp}@example.com`, userPassword: 'A1b2@-C3d4$-E5f6!-G7h8#-J9k0%-L1m2@', }); } await new Promise(resolve => setTimeout(resolve, 100)); // Simulate the hook firing for a DELETE on the principal account, whose DN // sits in the applicative branch. Without the guard this would call // deleteApplicativeAccount(mail) and cascade-delete every entry matching // the mail (principal + both app accounts). await appAccountsConsistency.hooks.onLdapMailChange!( testApplicativeDN, `testuser-${timestamp}@example.com`, null ); await new Promise(resolve => setTimeout(resolve, 100)); // The guard must have made it a no-op: all entries are still present. for (const dn of [testApplicativeDN, appAccount1DN, appAccount2DN]) { const result = await dm.ldap.search( { scope: 'base', paged: false }, dn ); expect( (result as any).searchEntries, `${dn} should still exist (no cascade)` ).to.have.lengthOf(1); } }); }); describe('Configuration', () => { it('should throw error if applicative_account_base is not configured', () => { const dmTest = new DM(); dmTest.config.applicative_account_base = undefined; expect(() => new AppAccountsConsistency(dmTest)).to.throw( /applicative_account_base configuration is required/ ); }); }); }); linagora-ldap-rest-16e557e/test/plugins/twake/calendarResources.test.ts000066400000000000000000000276601522642357000263300ustar00rootroot00000000000000import nock from 'nock'; import { DM } from '../../../src/bin'; import CalendarResources from '../../../src/plugins/twake/calendarResources'; import { expect } from 'chai'; import OnLdapChange from '../../../src/plugins/ldap/onChange'; import LdapFlat from '../../../src/plugins/ldap/flatGeneric'; describe('Calendar Resources Plugin', function () { // Skip all tests if required env vars are not set if ( !process.env.DM_LDAP_DN || !process.env.DM_LDAP_PWD || !process.env.DM_LDAP_BASE ) { // eslint-disable-next-line no-console console.warn( 'Skipping Calendar Resources tests: DM_LDAP_DN or DM_LDAP_PWD or DM_LDAP_BASE not set' ); // @ts-ignore this.skip?.(); return; } let resourceBase: string; let testResourceDN: string; let dm: DM; let calendarResources: CalendarResources; let ldapFlat: LdapFlat; let resourceInstance: any; // The resources instance from ldapFlat let scope: nock.Scope; before(function () { nock.disableNetConnect(); }); after(function () { nock.cleanAll(); nock.enableNetConnect(); }); beforeEach(async function () { this.timeout(5000); dm = new DM(); // Add schema path for ldapFlat dm.config.ldap_flat_schema = [ 'test/fixtures/calendar-resources-schema.json', ]; // Force ldap_base from env BEFORE ready (in case another test modified process.env) dm.config.ldap_base = process.env.DM_LDAP_BASE; await dm.ready; // Initialize resource paths from env resourceBase = `ou=resources,${process.env.DM_LDAP_BASE}`; testResourceDN = `cn=Conference Room A,${resourceBase}`; // Ensure ou=resources exists BEFORE creating plugins try { await dm.ldap.add(resourceBase, { objectClass: ['organizationalUnit', 'top'], ou: 'resources', }); } catch (err) { // Ignore if already exists } calendarResources = new CalendarResources(dm); ldapFlat = new LdapFlat(dm); resourceInstance = ldapFlat.instances[0]; await dm.registerPlugin('onLdapChange', new OnLdapChange(dm)); await dm.registerPlugin('ldapFlat', ldapFlat); await dm.registerPlugin('calendarResources', calendarResources); }); afterEach(async () => { // Clean up test data try { await dm.ldap.delete(testResourceDN); } catch (err) { // Ignore errors if the entry does not exist } }); it('should create resource in Calendar when added to LDAP', async () => { // Track API calls let calendarApiCalled = false; const apiScope = nock( process.env.DM_CALENDAR_WEBADMIN_URL || 'http://localhost:8080' ) .post('/resources', body => { calendarApiCalled = true; expect(body).to.have.property('name', 'Conference Room A'); expect(body).to.have.property('description', 'Large meeting room'); return true; }) .reply(201); const res = await resourceInstance.addEntry('Conference Room A', { description: 'Large meeting room', }); expect(res).to.be.true; // Wait for async hooks to complete await new Promise(resolve => setTimeout(resolve, 50)); expect(calendarApiCalled).to.be.true; apiScope.persist(false); nock.cleanAll(); }); it('should update resource in Calendar when modified in LDAP', async () => { // Mock both create and update API calls const createScope = nock( process.env.DM_CALENDAR_WEBADMIN_URL || 'http://localhost:8080' ) .post('/resources') .reply(201); // First create the resource await resourceInstance.addEntry('Conference Room A', { description: 'Large meeting room', }); // Wait for create hook to complete await new Promise(resolve => setTimeout(resolve, 50)); // Track update API call let calendarApiCalled = false; const updateScope = nock( process.env.DM_CALENDAR_WEBADMIN_URL || 'http://localhost:8080' ) .patch('/resources/Conference%20Room%20A', body => { calendarApiCalled = true; expect(body).to.have.property('description', 'Updated description'); return true; }) .reply(204); // Then modify it const res = await resourceInstance.modifyEntry(testResourceDN, { replace: { description: 'Updated description' }, }); expect(res).to.be.true; // Wait for async hooks to complete await new Promise(resolve => setTimeout(resolve, 50)); expect(calendarApiCalled).to.be.true; createScope.persist(false); updateScope.persist(false); nock.cleanAll(); }); it('should delete resource from Calendar when deleted from LDAP', async () => { // Mock create API call const createScope = nock( process.env.DM_CALENDAR_WEBADMIN_URL || 'http://localhost:8080' ) .post('/resources') .reply(201); // First create the resource await resourceInstance.addEntry('Conference Room A', { description: 'Large meeting room', }); // Wait for create hook to complete await new Promise(resolve => setTimeout(resolve, 50)); // Track delete API call let calendarApiCalled = false; const deleteScope = nock( process.env.DM_CALENDAR_WEBADMIN_URL || 'http://localhost:8080' ) .delete('/resources/Conference%20Room%20A') .reply(function () { calendarApiCalled = true; return [204]; }); // Then delete it const res = await resourceInstance.deleteEntry(testResourceDN); expect(res).to.be.true; // Wait for async hooks to complete await new Promise(resolve => setTimeout(resolve, 50)); expect(calendarApiCalled).to.be.true; createScope.persist(false); deleteScope.persist(false); nock.cleanAll(); }); describe('deleteUserData', () => { it('should call POST /users/{mail}?action=deleteData and return taskId', async () => { const deleteDataScope = nock( process.env.DM_CALENDAR_WEBADMIN_URL || 'http://localhost:8080' ) .post('/users/user@test.org?action=deleteData') .reply(201, { taskId: 'calendar-task-123' }); const result = await calendarResources.deleteUserData('user@test.org'); expect(result).to.deep.equal({ taskId: 'calendar-task-123' }); expect(deleteDataScope.isDone()).to.be.true; nock.cleanAll(); }); it('should return null and log error on non-OK response', async () => { const deleteDataScope = nock( process.env.DM_CALENDAR_WEBADMIN_URL || 'http://localhost:8080' ) .post('/users/baduser@test.org?action=deleteData') .reply(400, { error: 'Bad request' }); const result = await calendarResources.deleteUserData('baduser@test.org'); expect(result).to.be.null; expect(deleteDataScope.isDone()).to.be.true; nock.cleanAll(); }); }); describe('registered users sync', () => { let userDN: string; const calendarUrl = process.env.DM_CALENDAR_WEBADMIN_URL || 'http://localhost:8080'; beforeEach(async () => { userDN = `uid=caluser,${process.env.DM_LDAP_BASE}`; try { await dm.ldap.add(userDN, { objectClass: ['inetOrgPerson', 'top'], uid: 'caluser', cn: 'Benoit TELLIER', givenName: 'Benoit', sn: 'TELLIER', mail: 'caluser@test.org', }); } catch (err) { // Ignore if already exists } // Creating the entry fires onLdapChange with the mail attribute present, // which the plugin treats as an addition and skips — so no stray // /registeredUsers call races with the interceptors set up below. }); afterEach(async () => { try { await dm.ldap.delete(userDN); } catch (err) { // Ignore if it does not exist } nock.cleanAll(); }); it('lists registered users and PATCHes the one matching the email', async () => { let patchBody: Record | null = null; let patchUri = ''; const scope = nock(calendarUrl) .get('/registeredUsers') .reply(200, [ { id: '5f50a663', email: 'caluser@test.org', firstname: 'Old', lastname: 'Name', }, { id: 'other', email: 'someoneelse@test.org' }, ]) .patch('/registeredUsers', body => { patchBody = body as Record; return true; }) .query(true) .reply(function (uri) { patchUri = uri; return [204]; }); await calendarResources.syncRegisteredUser('test', userDN); expect(scope.isDone()).to.be.true; expect(patchUri).to.contain('id=5f50a663'); expect(patchBody).to.deep.equal({ email: 'caluser@test.org', firstname: 'Benoit', lastname: 'TELLIER', }); }); it('looks up by the OLD email on mail change and PATCHes the new email', async () => { let patchBody: Record | null = null; let patchUri = ''; const scope = nock(calendarUrl) .get('/registeredUsers') .reply(200, [ { id: 'abc123', email: 'old@test.org', firstname: 'Benoit', lastname: 'TELLIER', }, ]) .patch('/registeredUsers', body => { patchBody = body as Record; return true; }) .query(true) .reply(function (uri) { patchUri = uri; return [204]; }); // LDAP now holds caluser@test.org; Calendar still has old@test.org await calendarResources.hooks.onLdapChange!(userDN, { mail: ['old@test.org', 'caluser@test.org'], }); expect(scope.isDone()).to.be.true; expect(patchUri).to.contain('id=abc123'); expect(patchBody).to.deep.equal({ email: 'caluser@test.org', firstname: 'Benoit', lastname: 'TELLIER', }); }); it('does not PATCH when the user is not registered in Calendar', async () => { const scope = nock(calendarUrl) .get('/registeredUsers') .reply(200, [{ id: 'other', email: 'someoneelse@test.org' }]); await calendarResources.syncRegisteredUser('test', userDN); // GET happened, no PATCH was issued (would throw on unmocked request) expect(scope.isDone()).to.be.true; }); it('skips sync when the mail is added (no previous mail)', async () => { // No nock scope: any HTTP call would throw (netConnect disabled) await calendarResources.hooks.onLdapChange!(userDN, { mail: [null, 'caluser@test.org'], }); // Reaching here without a thrown unmocked-request error proves no call expect(nock.pendingMocks()).to.have.length(0); }); it('syncs names when a configured name attribute changes', async () => { let patchBody: Record | null = null; let patchUri = ''; const scope = nock(calendarUrl) .get('/registeredUsers') .reply(200, [ { id: 'nm1', email: 'caluser@test.org', firstname: 'Old', lastname: 'Name', }, ]) .patch('/registeredUsers', body => { patchBody = body as Record; return true; }) .query(true) .reply(function (uri) { patchUri = uri; return [204]; }); // sn is the default configured lastname attribute await calendarResources.hooks.onLdapChange!(userDN, { sn: ['Name', 'TELLIER'], }); expect(scope.isDone()).to.be.true; expect(patchUri).to.contain('id=nm1'); expect(patchBody).to.deep.equal({ email: 'caluser@test.org', firstname: 'Benoit', lastname: 'TELLIER', }); }); it('ignores changes to unrelated attributes', async () => { // No nock scope: any HTTP call would throw (netConnect disabled) await calendarResources.hooks.onLdapChange!(userDN, { description: ['before', 'after'], }); expect(nock.pendingMocks()).to.have.length(0); }); }); }); linagora-ldap-rest-16e557e/test/plugins/twake/clouderyProvision.test.ts000066400000000000000000000555201522642357000264170ustar00rootroot00000000000000import nock from 'nock'; import { expect } from 'chai'; import { DM } from '../../../src/bin'; import ClouderyProvision from '../../../src/plugins/twake/clouderyProvision'; import type { ScimUser } from '../../../src/plugins/scim/types'; const CLOUDERY = 'http://cloudery.test'; const B2B_BRANCH = 'ou=b2b,dc=twake,dc=local'; const USER_BASE = 'ou=users,ou=b2b,dc=twake,dc=local'; interface PublishCall { exchange: string; routingKey: string; message: Record; } class StubRabbitMq { name = 'rabbitmq'; calls: PublishCall[] = []; isAvailable(): boolean { return true; } async publish( exchange: string, routingKey: string, message: Record ): Promise { this.calls.push({ exchange, routingKey, message }); } } interface ModifyCall { dn: string; changes: Record; } /** Minimal stand-in for this.server.ldap used by the plugin. */ class StubLdap { modifyCalls: ModifyCall[] = []; // entry returned by a base-scope search keyed by dn entries: Record> = {}; async modify(dn: string, changes: Record): Promise { this.modifyCalls.push({ dn, changes }); return true; } // eslint-disable-next-line @typescript-eslint/no-explicit-any async search(_opts: any, base: string): Promise { const entry = this.entries[base]; return { searchEntries: entry ? [entry] : [] }; } } // Express-like request stub: carries the org id / base headers and the token. function makeReq(orgId?: string, orgBase?: string, role?: string): unknown { const headers: Record = {}; if (orgId) headers['x-cloudery-org-id'] = orgId; if (orgBase) headers['x-cloudery-org-base'] = orgBase; if (role) headers['x-cloudery-org-role'] = role; return { user: 'b2b-token', headers, query: {}, get(name: string): string | undefined { return headers[name.toLowerCase()]; }, }; } function configure(dm: DM): void { dm.config.cloudery_manager_url = CLOUDERY; dm.config.cloudery_manager_token = 'tok'; dm.config.cloudery_offer = 'b2b_twake_default'; dm.config.cloudery_domain = 'twake.app'; dm.config.cloudery_user_branch = B2B_BRANCH; dm.config.cloudery_fqdn_attribute = 'twakeWorkspaceUrl'; dm.config.cloudery_default_locale = 'en'; dm.config.cloudery_workflow_poll_interval_ms = 1; dm.config.cloudery_workflow_max_attempts = 5; dm.config.scim_user_base = USER_BASE; dm.config.scim_user_base_header = 'x-cloudery-org-base'; dm.config.scim_base_header_root = B2B_BRANCH; dm.config.scim_user_rdn_attribute = 'uid'; dm.config.rabbitmq_url = 'amqp://x'; } describe('ClouderyProvision plugin', () => { let dm: DM; let plugin: ClouderyProvision; let rabbit: StubRabbitMq; let ldap: StubLdap; before(() => nock.disableNetConnect()); after(() => { nock.cleanAll(); nock.enableNetConnect(); }); beforeEach(async () => { dm = new DM(); configure(dm); await dm.ready; rabbit = new StubRabbitMq(); ldap = new StubLdap(); // eslint-disable-next-line @typescript-eslint/no-explicit-any dm.loadedPlugins['rabbitmq'] = rabbit as any; // eslint-disable-next-line @typescript-eslint/no-explicit-any dm.ldap = ldap as any; plugin = new ClouderyProvision(dm); }); afterEach(() => nock.cleanAll()); // Drive a full create: awaited pre-hook then the (normally fire-and-forget) // done hook, both awaited here so assertions see the finished work. async function create(user: ScimUser, req: unknown): Promise { const pre = plugin.hooks?.scimusercreate as ( a: [ScimUser, unknown] ) => Promise; await pre([user, req]); const done = plugin.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await done(user); } async function remove(id: string, req: unknown): Promise { const pre = plugin.hooks?.scimuserdelete as ( a: [string, unknown] ) => Promise<[string, unknown]>; // The core deletes the id the hook returns, not the raw one. const [finalId] = await pre([id, req]); const done = plugin.hooks?.scimuserdeletedone as ( i: string ) => Promise; await done(finalId as string); } const user: ScimUser = { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'john.doe', emails: [{ value: 'john.doe@acme.com', primary: true }], phoneNumbers: [{ value: '+33600000000', primary: true }], displayName: 'John Doe', }; describe('create', () => { it('provisions via Cloudery, writes the fqdn, and publishes user.created', async () => { let body: Record = {}; const scope = nock(CLOUDERY) .post('/api/v1/instances', b => { body = b as Record; return true; }) .matchHeader('authorization', 'Bearer tok') .reply(200, { id: 'inst-1', fqdn: 'johndoeacme123.twake.app', workflow: 'wf-1', }) .get('/api/v1/workflows/wf-1') .reply(200, { status: 'succeeded' }); await create(user, makeReq('acme123')); expect(scope.isDone(), 'cloudery POST + workflow poll').to.equal(true); // slug = oidc = normalizeNickname(userName) + orgId (dots stripped) expect(body.slug).to.equal('johndoeacme123'); expect(body.oidc).to.equal('johndoeacme123'); expect(body.org_id).to.equal('acme123'); expect(body.org_domain).to.equal('acme.com'); expect(body.offer).to.equal('b2b_twake_default'); expect(body.domain).to.equal('twake.app'); expect(body.email).to.equal('john.doe@acme.com'); // role/phones are NOT sent to Cloudery; they are stamped on the entry expect(body).to.not.have.property('twakeOrganizationRole'); expect(body).to.not.have.property('twakePhones'); // fqdn, org id, role (default member) and phones written to the entry for // the admin panel to read expect(ldap.modifyCalls).to.have.length(1); expect(ldap.modifyCalls[0].dn).to.equal(`uid=john.doe,${USER_BASE}`); expect(ldap.modifyCalls[0].changes).to.deep.equal({ replace: { twakeWorkspaceUrl: 'johndoeacme123.twake.app', twakeOrganizationId: 'acme123', twakeOrganizationRole: 'member', twakeInvited: 'TRUE', cn: 'john.doe', twakePhones: JSON.stringify([ { number: '+33600000000', primary: true }, ]), }, }); // published on the same contract as cozyProvision (+ organizationId) expect(rabbit.calls).to.have.length(1); const call = rabbit.calls[0]; expect(call.exchange).to.equal('auth'); expect(call.routingKey).to.equal('user.created'); expect(call.message).to.deep.equal({ twakeId: 'john.doe', domain: 'acme.com', organizationDomain: 'acme.com', workplaceFqdn: 'johndoeacme123.twake.app', organizationId: 'acme123', internalEmail: 'john.doe@acme.com', mobile: '+33600000000', }); }); it('lowercases an uppercase userName for the uid, slug, cn and twakeId', async () => { let body: Record = {}; const scope = nock(CLOUDERY) .post('/api/v1/instances', b => { body = b as Record; return true; }) .reply(200, { id: 'inst-1', fqdn: 'johndoeacme123.twake.app', workflow: 'wf-1', }) .get('/api/v1/workflows/wf-1') .reply(200, { status: 'succeeded' }); const mixedCaseUser: ScimUser = { ...user, userName: 'John.Doe' }; await create(mixedCaseUser, makeReq('acme123')); expect(scope.isDone()).to.equal(true); // the hook lowercases userName, so the core builds a lowercase uid expect(mixedCaseUser.userName).to.equal('john.doe'); expect(body.slug).to.equal('johndoeacme123'); expect(body.oidc).to.equal('johndoeacme123'); expect(ldap.modifyCalls[0].dn).to.equal(`uid=john.doe,${USER_BASE}`); expect( (ldap.modifyCalls[0].changes as { replace: Record }) .replace.cn ).to.equal('john.doe'); expect(rabbit.calls[0].message.twakeId).to.equal('john.doe'); }); it('skips instance creation when an instance for the slug already exists', async () => { // Re-import scenario: the LDAP entry was recreated but the Cloudery // instance for this slug survived. The plugin must reuse it instead of // letting Cloudery mint a numbered duplicate (slug2). Note: no POST // /api/v1/instances is registered, so any create attempt fails the test. const scope = nock(CLOUDERY) .get('/api/v2/instances') .query(q => q.fqdn === 'johndoeacme123.twake.app') .reply(200, { items: [{ _id: 'existing-uuid' }] }); await create(user, makeReq('acme123')); expect(scope.isDone(), 'cloudery existence lookup').to.equal(true); // The existing instance's fqdn is written back and the entry announced, // so registration can skip its own creation and downstream stays linked. expect(ldap.modifyCalls).to.have.length(1); expect(ldap.modifyCalls[0].dn).to.equal(`uid=john.doe,${USER_BASE}`); const changes = ldap.modifyCalls[0].changes as { replace: Record; }; expect(changes.replace.twakeWorkspaceUrl).to.equal( 'johndoeacme123.twake.app' ); expect(rabbit.calls).to.have.length(1); expect(rabbit.calls[0].message.workplaceFqdn).to.equal( 'johndoeacme123.twake.app' ); }); it('trims whitespace in the email and org id before use', async () => { let body: Record = {}; const scope = nock(CLOUDERY) .post('/api/v1/instances', b => { body = b as Record; return true; }) .reply(200, { id: 'inst-1', fqdn: 'johndoeacme123.twake.app', workflow: 'wf-1', }) .get('/api/v1/workflows/wf-1') .reply(200, { status: 'succeeded' }); const spacedUser: ScimUser = { ...user, emails: [{ value: ' john.doe@acme.com ', primary: true }], }; await create(spacedUser, makeReq(' acme123 ')); expect(scope.isDone()).to.equal(true); expect(body.email).to.equal('john.doe@acme.com'); expect(body.internal_email).to.equal('john.doe@acme.com'); expect(body.org_domain).to.equal('acme.com'); expect(body.org_id).to.equal('acme123'); expect(body.slug).to.equal('johndoeacme123'); expect(ldap.modifyCalls[0].changes).to.deep.equal({ replace: { twakeWorkspaceUrl: 'johndoeacme123.twake.app', twakeOrganizationId: 'acme123', twakeOrganizationRole: 'member', twakeInvited: 'TRUE', cn: 'john.doe', twakePhones: JSON.stringify([ { number: '+33600000000', primary: true }, ]), }, }); }); it('marks the user as invited (twakeInvited=TRUE) on the entry', async () => { const scope = nock(CLOUDERY) .post('/api/v1/instances') .reply(200, { id: 'inst-1', fqdn: 'johndoeacme123.twake.app', workflow: 'wf-1', }) .get('/api/v1/workflows/wf-1') .reply(200, { status: 'succeeded' }); await create(user, makeReq('acme123')); expect(scope.isDone()).to.equal(true); const changes = ldap.modifyCalls[0].changes as { replace: Record; }; expect(changes.replace.twakeInvited).to.equal('TRUE'); }); it('uses the configured attribute name for the invited flag', async () => { dm.config.cloudery_invited_attribute = 'customInvited'; const p = new ClouderyProvision(dm); const scope = nock(CLOUDERY) .post('/api/v1/instances') .reply(200, { id: 'inst-1', fqdn: 'johndoeacme123.twake.app', workflow: 'wf-1', }) .get('/api/v1/workflows/wf-1') .reply(200, { status: 'succeeded' }); const pre = p.hooks?.scimusercreate as ( a: [ScimUser, unknown] ) => Promise; await pre([user, makeReq('acme123')]); const done = p.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await done(user); expect(scope.isDone()).to.equal(true); const changes = ldap.modifyCalls[0].changes as { replace: Record; }; expect(changes.replace.customInvited).to.equal('TRUE'); expect(changes.replace).to.not.have.property('twakeInvited'); }); it('omits twakePhones when the user has no phone numbers', async () => { const scope = nock(CLOUDERY) .post('/api/v1/instances') .reply(200, { id: 'inst-1', fqdn: 'johndoeacme123.twake.app', workflow: 'wf-1', }) .get('/api/v1/workflows/wf-1') .reply(200, { status: 'succeeded' }); const { phoneNumbers: _omitted, ...noPhoneUser } = user; await create(noPhoneUser as ScimUser, makeReq('acme123')); expect(scope.isDone()).to.equal(true); const changes = ldap.modifyCalls[0].changes as { replace: Record; }; expect(changes.replace.twakeOrganizationRole).to.equal('member'); expect(changes.replace).to.not.have.property('twakePhones'); }); it('uses the role from the request header when supplied', async () => { const scope = nock(CLOUDERY) .post('/api/v1/instances') .reply(200, { id: 'inst-1', fqdn: 'johndoeacme123.twake.app', workflow: 'wf-1', }) .get('/api/v1/workflows/wf-1') .reply(200, { status: 'succeeded' }); await create(user, makeReq('acme123', undefined, 'owner')); expect(scope.isDone()).to.equal(true); const changes = ldap.modifyCalls[0].changes as { replace: Record; }; expect(changes.replace.twakeOrganizationRole).to.equal('owner'); }); it('falls back to the default role for an unknown header value', async () => { const scope = nock(CLOUDERY) .post('/api/v1/instances') .reply(200, { id: 'inst-1', fqdn: 'johndoeacme123.twake.app', workflow: 'wf-1', }) .get('/api/v1/workflows/wf-1') .reply(200, { status: 'succeeded' }); await create(user, makeReq('acme123', undefined, 'superuser')); expect(scope.isDone()).to.equal(true); const changes = ldap.modifyCalls[0].changes as { replace: Record; }; expect(changes.replace.twakeOrganizationRole).to.equal('member'); }); it('normalises an invalid configured default role to member', async () => { dm.config.cloudery_default_org_role = 'Administrator'; const p = new ClouderyProvision(dm); nock(CLOUDERY) .post('/api/v1/instances') .reply(200, { id: 'inst-1', fqdn: 'johndoeacme123.twake.app', workflow: 'wf-1', }) .get('/api/v1/workflows/wf-1') .reply(200, { status: 'succeeded' }); const pre = p.hooks?.scimusercreate as ( a: [ScimUser, unknown] ) => Promise; await pre([user, makeReq('acme123')]); const done = p.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await done(user); const changes = ldap.modifyCalls[0].changes as { replace: Record; }; expect(changes.replace.twakeOrganizationRole).to.equal('member'); }); it('trims whitespace and casing from a header role and phone numbers', async () => { const scope = nock(CLOUDERY) .post('/api/v1/instances') .reply(200, { id: 'inst-1', fqdn: 'johndoeacme123.twake.app', workflow: 'wf-1', }) .get('/api/v1/workflows/wf-1') .reply(200, { status: 'succeeded' }); const spacedUser: ScimUser = { ...user, phoneNumbers: [{ value: ' +33600000000 ', primary: true }], }; await create(spacedUser, makeReq('acme123', undefined, ' ADMIN ')); expect(scope.isDone()).to.equal(true); const changes = ldap.modifyCalls[0].changes as { replace: Record; }; expect(changes.replace.twakeOrganizationRole).to.equal('admin'); expect(changes.replace.twakePhones).to.equal( JSON.stringify([{ number: '+33600000000', primary: true }]) ); }); it('inserts under the org branch supplied by the base header', async () => { const orgBase = `ou=users,ou=acme123,${B2B_BRANCH}`; nock(CLOUDERY) .post('/api/v1/instances') .reply(200, { id: 'inst-1', fqdn: 'johndoeacme123.twake.app', workflow: 'wf-1', }) .get('/api/v1/workflows/wf-1') .reply(200, { status: 'succeeded' }); await create(user, makeReq('acme123', orgBase)); expect(ldap.modifyCalls).to.have.length(1); expect(ldap.modifyCalls[0].dn).to.equal(`uid=john.doe,${orgBase}`); }); it('publishes with a configured routing key', async () => { dm.config.cozy_user_created_routing_key = 'custom.user.created'; const p = new ClouderyProvision(dm); nock(CLOUDERY) .post('/api/v1/instances') .reply(200, { id: 'inst-1', fqdn: 'johndoeacme123.twake.app', workflow: 'wf-1', }) .get('/api/v1/workflows/wf-1') .reply(200, { status: 'succeeded' }); const pre = p.hooks?.scimusercreate as ( a: [ScimUser, unknown] ) => Promise; await pre([user, makeReq('acme123')]); const done = p.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await done(user); expect(rabbit.calls).to.have.length(1); expect(rabbit.calls[0].exchange).to.equal('auth'); expect(rabbit.calls[0].routingKey).to.equal('custom.user.created'); }); it('is inert for users outside the configured B2B branch', async () => { dm.config.scim_user_base = 'ou=users,dc=twake,dc=local'; // not under b2b const p = new ClouderyProvision(dm); const pre = p.hooks?.scimusercreate as ( a: [ScimUser, unknown] ) => Promise; await pre([user, makeReq('acme123')]); const done = p.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await done(user); // No HTTP intercept registered → a call would throw; none happens. expect(rabbit.calls).to.have.length(0); expect(ldap.modifyCalls).to.have.length(0); }); it('skips provisioning when the org id header is missing', async () => { await create(user, makeReq(undefined)); expect(rabbit.calls).to.have.length(0); expect(ldap.modifyCalls).to.have.length(0); }); it('does not write fqdn or publish when the workflow fails', async () => { const scope = nock(CLOUDERY) .post('/api/v1/instances') .reply(200, { id: 'inst-1', fqdn: 'x.twake.app', workflow: 'wf-2' }) .get('/api/v1/workflows/wf-2') .reply(200, { status: 'failed' }); await create(user, makeReq('acme123')); expect(scope.isDone()).to.equal(true); expect(ldap.modifyCalls).to.have.length(0); expect(rabbit.calls).to.have.length(0); }); }); describe('delete', () => { it('deletes the Cloudery instance and publishes domain.user.deleted', async () => { const dn = `uid=john.doe,${USER_BASE}`; ldap.entries[dn] = { twakeWorkspaceUrl: 'johndoeacme123.twake.app', mail: 'john.doe@acme.com', }; const scope = nock(CLOUDERY) .get('/api/v2/instances') .query(q => q.fqdn === 'johndoeacme123.twake.app') .reply(200, { items: [{ _id: 'uuid-1' }] }) .delete('/api/v1/instances/uuid-1') .query(q => q.user_request === 'true') .reply(200, { workflow: 'wf-del' }); await remove('john.doe', makeReq('acme123')); expect(scope.isDone(), 'search + delete').to.equal(true); expect(rabbit.calls).to.have.length(1); const call = rabbit.calls[0]; expect(call.exchange).to.equal('b2b'); expect(call.routingKey).to.equal('domain.user.deleted'); expect(call.message).to.deep.equal({ workplaceFqdn: 'johndoeacme123.twake.app', domain: 'acme.com', }); }); it('does not publish when the Cloudery instance is not found', async () => { const dn = `uid=john.doe,${USER_BASE}`; ldap.entries[dn] = { twakeWorkspaceUrl: 'johndoeacme123.twake.app', mail: 'john.doe@acme.com', }; const scope = nock(CLOUDERY) .get('/api/v2/instances') .query(q => q.fqdn === 'johndoeacme123.twake.app') .reply(200, { items: [] }); await remove('john.doe', makeReq('acme123')); expect(scope.isDone(), 'lookup attempted').to.equal(true); // teardown did not happen → downstream must not be told it did expect(rabbit.calls).to.have.length(0); }); it('strips a protocol from a stored workspace url before lookup', async () => { const dn = `uid=john.doe,${USER_BASE}`; ldap.entries[dn] = { twakeWorkspaceUrl: 'https://johndoeacme123.twake.app', mail: 'john.doe@acme.com', }; const scope = nock(CLOUDERY) .get('/api/v2/instances') .query(q => q.fqdn === 'johndoeacme123.twake.app') .reply(200, { items: [{ _id: 'uuid-1' }] }) .delete('/api/v1/instances/uuid-1') .query(q => q.user_request === 'true') .reply(200, { workflow: 'wf-del' }); await remove('john.doe', makeReq('acme123')); expect(scope.isDone(), 'bare fqdn used for lookup').to.equal(true); expect(rabbit.calls[0].message).to.deep.equal({ workplaceFqdn: 'johndoeacme123.twake.app', domain: 'acme.com', }); }); it('lowercases an uppercase id before resolving and deleting the instance', async () => { const dn = `uid=john.doe,${USER_BASE}`; ldap.entries[dn] = { twakeWorkspaceUrl: 'johndoeacme123.twake.app', mail: 'john.doe@acme.com', }; const scope = nock(CLOUDERY) .get('/api/v2/instances') .query(q => q.fqdn === 'johndoeacme123.twake.app') .reply(200, { items: [{ _id: 'uuid-1' }] }) .delete('/api/v1/instances/uuid-1') .query(q => q.user_request === 'true') .reply(200, { workflow: 'wf-del' }); await remove('John.Doe', makeReq('acme123')); expect(scope.isDone(), 'lowercased dn resolved the instance').to.equal( true ); expect(rabbit.calls).to.have.length(1); expect(rabbit.calls[0].message.workplaceFqdn).to.equal( 'johndoeacme123.twake.app' ); }); it('skips when the entry has no stored fqdn (not ours)', async () => { await remove('jane.doe', makeReq('acme123')); expect(rabbit.calls).to.have.length(0); }); }); }); linagora-ldap-rest-16e557e/test/plugins/twake/cozyProvision.test.ts000066400000000000000000000314031522642357000255470ustar00rootroot00000000000000import nock from 'nock'; import { expect } from 'chai'; import { DM } from '../../../src/bin'; import CozyProvision from '../../../src/plugins/twake/cozyProvision'; import type { ScimUser } from '../../../src/plugins/scim/types'; interface PublishCall { exchange: string; routingKey: string; message: Record; } /** * Stand-in for the shared `rabbitmq` plugin, injected into the DM's * loaded-plugin registry so cozyProvision publishes through it without a * real broker. */ class StubRabbitMq { name = 'rabbitmq'; calls: PublishCall[] = []; isAvailable(): boolean { return true; } async publish( exchange: string, routingKey: string, message: Record ): Promise { this.calls.push({ exchange, routingKey, message }); } } const COZY_URL = 'http://cozyt:6060'; describe('CozyProvision plugin', () => { let dm: DM; let plugin: CozyProvision; let rabbit: StubRabbitMq; before(() => { nock.disableNetConnect(); }); after(() => { nock.cleanAll(); nock.enableNetConnect(); }); beforeEach(async () => { dm = new DM(); dm.config.cozy_admin_url = COZY_URL; dm.config.cozy_admin_user = 'admin'; dm.config.cozy_admin_passphrase = 'admin'; dm.config.cozy_org_id = 'twp-test'; dm.config.cozy_org_domain = 'twake.local'; dm.config.cozy_default_locale = 'fr'; // Non-empty url just so the lazy-init path is exercised; the stub // overrides actual broker access. dm.config.rabbitmq_url = 'amqp://guest:guest@rabbitmq:5672/'; await dm.ready; rabbit = new StubRabbitMq(); // eslint-disable-next-line @typescript-eslint/no-explicit-any dm.loadedPlugins['rabbitmq'] = rabbit as any; plugin = new CozyProvision(dm); }); afterEach(() => { nock.cleanAll(); }); describe('scimusercreatedone', () => { it('POSTs /instances on cozy admin and publishes user.created', async () => { const scope = nock(COZY_URL) .post('/instances') .query({ Domain: 'alice.twake.local', Locale: 'fr', Email: 'alice@twake.local', PublicName: 'alice', OrgID: 'twp-test', OrgDomain: 'twake.local', ContextName: 'default', Apps: 'home,drive,settings,notes,dataproxy', OIDCID: 'alice', }) .matchHeader('authorization', /^Basic /) .reply(201, { ok: true }) .patch('/instances/alice.twake.local') .query({ OnboardingFinished: 'true' }) .reply(200, { ok: true }); const user: ScimUser = { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], id: 'alice', userName: 'alice', emails: [{ value: 'alice@twake.local', primary: true }], }; const hook = plugin.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await hook(user); expect(scope.isDone(), 'cozy admin POST + PATCH').to.be.true; expect(rabbit.calls).to.have.length(1); const call = rabbit.calls[0]; expect(call.exchange).to.equal('auth'); expect(call.routingKey).to.equal('user.created'); expect(call.message).to.deep.include({ twakeId: 'alice', domain: 'twake.local', internalEmail: 'alice@twake.local', organizationDomain: 'twake.local', workplaceFqdn: 'alice.twake.local', }); }); it('publishes with a configured routing key', async () => { dm.config.cozy_user_created_routing_key = 'custom.user.created'; const p = new CozyProvision(dm); const scope = nock(COZY_URL) .post('/instances') .query(true) .reply(201, { ok: true }) .patch('/instances/ivy.twake.local') .query({ OnboardingFinished: 'true' }) .reply(200, { ok: true }); const user: ScimUser = { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'ivy', }; const hook = p.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await hook(user); expect(scope.isDone()).to.be.true; expect(rabbit.calls).to.have.length(1); expect(rabbit.calls[0].exchange).to.equal('auth'); expect(rabbit.calls[0].routingKey).to.equal('custom.user.created'); }); it('respects cozy_apps override', async () => { dm.config.cozy_apps = 'home,drive'; const p = new CozyProvision(dm); const scope = nock(COZY_URL) .post('/instances') .query(q => q.Apps === 'home,drive') .reply(201, { ok: true }) .patch('/instances/ivy.twake.local') .query({ OnboardingFinished: 'true' }) .reply(200, { ok: true }); const user: ScimUser = { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'ivy', }; const hook = p.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await hook(user); expect(scope.isDone()).to.be.true; }); it('omits Apps query param when cozy_apps is empty', async () => { dm.config.cozy_apps = ''; const p = new CozyProvision(dm); const scope = nock(COZY_URL) .post('/instances') .query(q => !('Apps' in q)) .reply(201, { ok: true }) .patch('/instances/jack.twake.local') .query({ OnboardingFinished: 'true' }) .reply(200, { ok: true }); const user: ScimUser = { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'jack', }; const hook = p.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await hook(user); expect(scope.isDone()).to.be.true; }); it('uses SCIM displayName / name as PublicName when provided', async () => { const scope = nock(COZY_URL) .post('/instances') .query( q => q.PublicName === 'Khaled Ferjani' && q.Phone === '+33600000000' ) .reply(201, { ok: true }) .patch('/instances/khaled.twake.local') .query({ OnboardingFinished: 'true' }) .reply(200, { ok: true }); const user: ScimUser = { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'khaled', displayName: 'Khaled Ferjani', phoneNumbers: [{ value: '+33600000000', primary: true }], }; const hook = plugin.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await hook(user); expect(scope.isDone()).to.be.true; }); it('forwards primary phoneNumber as mobile when provided', async () => { nock(COZY_URL) .post('/instances') .query(true) .reply(201, { ok: true }) .patch('/instances/grace.twake.local') .query({ OnboardingFinished: 'true' }) .reply(200, { ok: true }); const user: ScimUser = { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'grace', phoneNumbers: [ { value: '+33600000000', primary: true }, { value: '+33700000000' }, ], }; const hook = plugin.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await hook(user); expect(rabbit.calls).to.have.length(1); expect(rabbit.calls[0].message).to.deep.include({ twakeId: 'grace', mobile: '+33600000000', }); }); it('treats 409 as success and still publishes', async () => { const scope = nock(COZY_URL) .post('/instances') .query(true) .reply(409, { error: 'already exists' }); const user: ScimUser = { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'bob', }; const hook = plugin.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await hook(user); expect(scope.isDone()).to.be.true; expect(rabbit.calls).to.have.length(1); expect(rabbit.calls[0].routingKey).to.equal('user.created'); }); it('does not fail the hook when the onboarding PATCH errors', async () => { const scope = nock(COZY_URL) .post('/instances') .query(true) .reply(201, { ok: true }) .patch('/instances/heidi.twake.local') .query({ OnboardingFinished: 'true' }) .reply(500, { error: 'boom' }); const user: ScimUser = { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'heidi', }; const hook = plugin.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await hook(user); expect(scope.isDone()).to.be.true; // Publish still happens — PATCH failure must not abort the flow. expect(rabbit.calls).to.have.length(1); expect(rabbit.calls[0].routingKey).to.equal('user.created'); }); it('skips publish when cozy admin returns a hard error', async () => { const scope = nock(COZY_URL) .post('/instances') .query(true) .reply(500, { error: 'boom' }); const user: ScimUser = { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'carol', }; const hook = plugin.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await hook(user); expect(scope.isDone()).to.be.true; expect(rabbit.calls).to.have.length(0); }); it('uses user.locale when present', async () => { const scope = nock(COZY_URL) .post('/instances') .query(q => q.Locale === 'en') .reply(201, { ok: true }) .patch('/instances/dave.twake.local') .query({ OnboardingFinished: 'true' }) .reply(200, { ok: true }); const user: ScimUser = { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'dave', locale: 'en', }; const hook = plugin.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await hook(user); expect(scope.isDone()).to.be.true; }); it('skips when user has no userName/id', async () => { const user: ScimUser = { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], }; const hook = plugin.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await hook(user); // No HTTP call expected, no publish either expect(rabbit.calls).to.have.length(0); }); }); describe('scimuserdeletedone', () => { it('DELETEs the cozy instance and publishes domain.user.deleted', async () => { const scope = nock(COZY_URL) .delete('/instances/eve.twake.local') .reply(204); const hook = plugin.hooks?.scimuserdeletedone as ( id: string ) => Promise; await hook('eve'); expect(scope.isDone()).to.equal(true); expect(rabbit.calls).to.have.length(1); const call = rabbit.calls[0]; expect(call.exchange).to.equal('b2b'); expect(call.routingKey).to.equal('domain.user.deleted'); expect(call.message).to.deep.equal({ workplaceFqdn: 'eve.twake.local', domain: 'twake.local', }); }); it('treats 404 from cozy admin as success and still publishes', async () => { const scope = nock(COZY_URL) .delete('/instances/ghost.twake.local') .reply(404); const hook = plugin.hooks?.scimuserdeletedone as ( id: string ) => Promise; await hook('ghost'); expect(scope.isDone()).to.equal(true); expect(rabbit.calls).to.have.length(1); expect(rabbit.calls[0].routingKey).to.equal('domain.user.deleted'); }); it('does not publish when the destroy errors', async () => { const scope = nock(COZY_URL) .delete('/instances/broken.twake.local') .reply(500, { error: 'server error' }); const hook = plugin.hooks?.scimuserdeletedone as ( id: string ) => Promise; await hook('broken'); expect(scope.isDone()).to.equal(true); expect(rabbit.calls).to.have.length(0); }); }); describe('configuration safety', () => { it('does not call cozy admin when cozy_admin_url is empty', async () => { const dm2 = new DM(); dm2.config.cozy_admin_url = ''; dm2.config.cozy_org_domain = 'twake.local'; dm2.config.rabbitmq_url = 'amqp://x'; await dm2.ready; const rabbit2 = new StubRabbitMq(); // eslint-disable-next-line @typescript-eslint/no-explicit-any dm2.loadedPlugins['rabbitmq'] = rabbit2 as any; const p = new CozyProvision(dm2); const user: ScimUser = { schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], userName: 'frank', }; const hook = p.hooks?.scimusercreatedone as ( u: ScimUser ) => Promise; await hook(user); // No nock expectation set, no HTTP call attempted; no publish either. expect(rabbit2.calls).to.have.length(0); }); }); }); linagora-ldap-rest-16e557e/test/plugins/twake/drive.test.ts000066400000000000000000000633021522642357000237660ustar00rootroot00000000000000import nock from 'nock'; import type { ParsedUrlQuery } from 'querystring'; import { DM } from '../../../src/bin'; import Drive from '../../../src/plugins/twake/drive'; import { expect } from 'chai'; import OnLdapChange from '../../../src/plugins/ldap/onChange'; import { skipIfMissingEnvVars, LDAP_ENV_VARS } from '../../helpers/env'; // Default Drive webadmin URL for tests (using nock for mocking) const TWAKE_DRIVE_WEBADMIN_URL = 'http://localhost:6060'; describe('Drive Plugin', () => { let testDN: string; let testDNDisplayName: string; let dm: DM; let drive: Drive; let scope: nock.Scope; before(function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS]); // Set default Drive webadmin URL for tests if not set if (!process.env.DM_TWAKE_DRIVE_WEBADMIN_URL) { process.env.DM_TWAKE_DRIVE_WEBADMIN_URL = TWAKE_DRIVE_WEBADMIN_URL; } // Initialize DNs after env vars are set testDN = `uid=testdriveuser,${process.env.DM_LDAP_BASE}`; testDNDisplayName = `uid=drivedisplayuser,${process.env.DM_LDAP_BASE}`; scope = nock( process.env.DM_TWAKE_DRIVE_WEBADMIN_URL || TWAKE_DRIVE_WEBADMIN_URL ).persist(); nock.disableNetConnect(); }); after(function () { if (scope) { scope.persist(false); } nock.cleanAll(); nock.enableNetConnect(); }); beforeEach(async () => { dm = new DM(); await dm.ready; drive = new Drive(dm); await dm.registerPlugin('onLdapChange', new OnLdapChange(dm)); await dm.registerPlugin('drive', drive); }); afterEach(async () => { // Clean up: delete the test entries if they exist try { await dm.ldap.delete(testDN); } catch (err) { // Ignore errors if the entry does not exist } try { await dm.ldap.delete(testDNDisplayName); } catch (err) { // Ignore errors if the entry does not exist } }); describe('Mail change propagation', () => { it('should update Cozy instance when mail changes', async () => { let apiCalled = false; const mailChangeScope = nock( process.env.DM_TWAKE_DRIVE_WEBADMIN_URL || TWAKE_DRIVE_WEBADMIN_URL ) .patch('/instances/testuser.mycozy.cloud') .query({ FromCloudery: 'true', Email: 'newmail@test.org', }) .reply(200, { success: true }); mailChangeScope.on('request', () => { apiCalled = true; }); const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Test User', sn: 'User', uid: 'testdriveuser', mail: 'oldmail@test.org', twakeCozyDomain: 'testuser.mycozy.cloud', }; let res = await dm.ldap.add(testDN, entry); expect(res).to.be.true; // Modify mail res = await dm.ldap.modify(testDN, { replace: { mail: 'newmail@test.org' }, }); expect(res).to.be.true; // Wait for onChange hook to execute await new Promise(resolve => setTimeout(resolve, 150)); expect(apiCalled).to.be.true; }); it('should skip mail change when user has no Cozy domain', async () => { let apiCalled = false; const noCozyScope = nock( process.env.DM_TWAKE_DRIVE_WEBADMIN_URL || TWAKE_DRIVE_WEBADMIN_URL ) .patch(/\/instances\/.*/) .reply(200, { success: true }); noCozyScope.on('request', () => { apiCalled = true; }); // Create user without twakeCozyDomain const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'Test User', sn: 'User', uid: 'testdriveuser', mail: 'oldmail@test.org', }; let res = await dm.ldap.add(testDN, entry); expect(res).to.be.true; // Modify mail res = await dm.ldap.modify(testDN, { replace: { mail: 'newmail@test.org' }, }); expect(res).to.be.true; // Wait for onChange hook to execute await new Promise(resolve => setTimeout(resolve, 150)); // API should NOT be called expect(apiCalled).to.be.false; }); it('should handle 404 response gracefully (instance not provisioned)', async () => { const notFoundScope = nock( process.env.DM_TWAKE_DRIVE_WEBADMIN_URL || TWAKE_DRIVE_WEBADMIN_URL ) .patch('/instances/notfound.mycozy.cloud') .query({ FromCloudery: 'true', Email: 'new@test.org', }) .reply(404, { error: 'Instance not found' }); const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Test User', sn: 'User', uid: 'testdriveuser', mail: 'old@test.org', twakeCozyDomain: 'notfound.mycozy.cloud', }; let res = await dm.ldap.add(testDN, entry); expect(res).to.be.true; // Modify mail - should not throw res = await dm.ldap.modify(testDN, { replace: { mail: 'new@test.org' }, }); expect(res).to.be.true; // Wait for onChange hook to execute await new Promise(resolve => setTimeout(resolve, 150)); expect(notFoundScope.isDone()).to.be.true; }); }); describe('Display name change propagation', () => { // Note: onLdapDisplayNameChange is triggered when cn, givenName, or sn changes, // not when displayName changes directly. Test with cn change instead. it('should update Cozy instance when cn changes', async () => { let callCount = 0; let lastPublicName = ''; // Mock that accepts any PublicName and tracks calls nock(process.env.DM_TWAKE_DRIVE_WEBADMIN_URL || TWAKE_DRIVE_WEBADMIN_URL) .patch('/instances/cnuser2.mycozy.cloud') .query((query: ParsedUrlQuery) => { return query.FromCloudery === 'true' && !!query.PublicName; }) .times(2) // Allow up to 2 calls (creation + modification) .reply(200, function (uri) { callCount++; const url = new URL(uri, 'http://localhost'); lastPublicName = url.searchParams.get('PublicName') || ''; return { success: true }; }); const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Old CN Name', sn: 'User', uid: 'drivedisplayuser', mail: 'cn2@test.org', twakeCozyDomain: 'cnuser2.mycozy.cloud', }; let res = await dm.ldap.add(testDNDisplayName, entry); expect(res).to.be.true; // Wait for ldapadddone hook to potentially execute await new Promise(resolve => setTimeout(resolve, 100)); // Modify cn - this triggers onLdapDisplayNameChange res = await dm.ldap.modify(testDNDisplayName, { replace: { cn: 'New CN Name' }, }); expect(res).to.be.true; // Wait for onChange hook to execute await new Promise(resolve => setTimeout(resolve, 200)); // Verify API was called at least once with the new name expect(callCount).to.be.greaterThan(0); expect(lastPublicName).to.equal('New CN Name'); }); it('should update Cozy instance when cn changes (fallback)', async () => { let apiCalled = false; const cnScope = nock( process.env.DM_TWAKE_DRIVE_WEBADMIN_URL || TWAKE_DRIVE_WEBADMIN_URL ) .patch('/instances/cnuser.mycozy.cloud') .query({ FromCloudery: 'true', PublicName: 'New CN Name', }) .reply(200, { success: true }); cnScope.on('request', () => { apiCalled = true; }); // Create user without displayName (uses cn as fallback) const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Old CN Name', sn: 'User', uid: 'drivedisplayuser', mail: 'cn@test.org', twakeCozyDomain: 'cnuser.mycozy.cloud', }; let res = await dm.ldap.add(testDNDisplayName, entry); expect(res).to.be.true; // Modify cn res = await dm.ldap.modify(testDNDisplayName, { replace: { cn: 'New CN Name' }, }); expect(res).to.be.true; // Wait for onChange hook to execute await new Promise(resolve => setTimeout(resolve, 150)); expect(apiCalled).to.be.true; }); it('should skip display name change when user has no Cozy domain', async () => { let apiCalled = false; const noCozyScope = nock( process.env.DM_TWAKE_DRIVE_WEBADMIN_URL || TWAKE_DRIVE_WEBADMIN_URL ) .patch(/\/instances\/.*/) .reply(200, { success: true }); noCozyScope.on('request', () => { apiCalled = true; }); // Create user without twakeCozyDomain const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'No Cozy User', sn: 'User', uid: 'drivedisplayuser', mail: 'nocozy@test.org', displayName: 'Old Name', }; let res = await dm.ldap.add(testDNDisplayName, entry); expect(res).to.be.true; // Modify displayName res = await dm.ldap.modify(testDNDisplayName, { replace: { displayName: 'New Name' }, }); expect(res).to.be.true; // Wait for onChange hook to execute await new Promise(resolve => setTimeout(resolve, 150)); // API should NOT be called expect(apiCalled).to.be.false; }); }); describe('Drive quota change propagation', () => { let testDNQuota: string; beforeEach(async () => { testDNQuota = `uid=drivequotauser,${process.env.DM_LDAP_BASE}`; // Clean up any existing entry try { await dm.ldap.delete(testDNQuota); } catch { // Entry may not exist, ignore } }); afterEach(async () => { try { await dm.ldap.delete(testDNQuota); } catch { // Entry may not exist, ignore } }); it('should update Twake Drive instance when drive quota changes', async () => { let apiCalled = false; let diskQuotaValue = ''; nock(process.env.DM_TWAKE_DRIVE_WEBADMIN_URL || TWAKE_DRIVE_WEBADMIN_URL) .patch('/instances/quotauser.twake.cloud') .query((query: ParsedUrlQuery) => { diskQuotaValue = String(query.DiskQuota || ''); return query.FromCloudery === 'true' && !!query.DiskQuota; }) .reply(200, function () { apiCalled = true; return { success: true }; }); const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Quota User', sn: 'User', uid: 'drivequotauser', mail: 'quota@test.org', twakeCozyDomain: 'quotauser.twake.cloud', twakeDriveQuota: '1073741824', // 1GB in bytes }; let res = await dm.ldap.add(testDNQuota, entry); expect(res).to.be.true; // Modify drive quota to 5GB res = await dm.ldap.modify(testDNQuota, { replace: { twakeDriveQuota: '5368709120' }, }); expect(res).to.be.true; // Wait for onChange hook to execute await new Promise(resolve => setTimeout(resolve, 150)); expect(apiCalled).to.be.true; expect(diskQuotaValue).to.equal('5368709120'); }); it('should skip drive quota change when user has no Twake Drive domain', async () => { let apiCalled = false; const noTwakeScope = nock( process.env.DM_TWAKE_DRIVE_WEBADMIN_URL || TWAKE_DRIVE_WEBADMIN_URL ) .patch(/\/instances\/.*/) .reply(200, { success: true }); noTwakeScope.on('request', () => { apiCalled = true; }); // Create user without twakeCozyDomain (but with twakeWhitePages for schema) const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'No Twake User', sn: 'User', uid: 'drivequotauser', mail: 'notwake@test.org', twakeDriveQuota: '1073741824', }; let res = await dm.ldap.add(testDNQuota, entry); expect(res).to.be.true; // Modify drive quota res = await dm.ldap.modify(testDNQuota, { replace: { twakeDriveQuota: '5368709120' }, }); expect(res).to.be.true; // Wait for onChange hook to execute await new Promise(resolve => setTimeout(resolve, 150)); // API should NOT be called expect(apiCalled).to.be.false; }); it('should skip drive quota change when quota is deleted', async () => { let apiCalled = false; const deleteScope = nock( process.env.DM_TWAKE_DRIVE_WEBADMIN_URL || TWAKE_DRIVE_WEBADMIN_URL ) .patch(/\/instances\/.*/) .query((query: ParsedUrlQuery) => { return !!query.DiskQuota; }) .reply(200, { success: true }); deleteScope.on('request', () => { apiCalled = true; }); const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Delete Quota User', sn: 'User', uid: 'drivequotauser', mail: 'deletequota@test.org', twakeCozyDomain: 'deletequota.twake.cloud', twakeDriveQuota: '1073741824', }; let res = await dm.ldap.add(testDNQuota, entry); expect(res).to.be.true; // Delete drive quota attribute res = await dm.ldap.modify(testDNQuota, { delete: ['twakeDriveQuota'], }); expect(res).to.be.true; // Wait for onChange hook to execute await new Promise(resolve => setTimeout(resolve, 150)); // API should NOT be called for quota deletion expect(apiCalled).to.be.false; }); }); describe('Public methods', () => { it('getCozyDomain should return the Cozy domain attribute', async () => { const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Cozy User', sn: 'User', uid: 'testdriveuser', mail: 'cozy@test.org', twakeCozyDomain: 'cozyuser.mycozy.cloud', }; await dm.ldap.add(testDN, entry); const domain = await drive.getCozyDomain(testDN); expect(domain).to.equal('cozyuser.mycozy.cloud'); }); it('getCozyDomain should return null if attribute is missing', async () => { const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'No Cozy User', sn: 'User', uid: 'testdriveuser', mail: 'nocozy@test.org', }; await dm.ldap.add(testDN, entry); const domain = await drive.getCozyDomain(testDN); expect(domain).to.be.null; }); it('getDisplayNameFromDN should return display name with fallback', async () => { // Test with displayName const entry1 = { objectClass: ['top', 'inetOrgPerson'], cn: 'CN Name', sn: 'User', uid: 'testdriveuser', mail: 'test@test.org', displayName: 'Display Name', }; await dm.ldap.add(testDN, entry1); let name = await drive.getDisplayNameFromDN(testDN); expect(name).to.equal('Display Name'); await dm.ldap.delete(testDN); // Test with cn fallback (no displayName) const entry2 = { objectClass: ['top', 'inetOrgPerson'], cn: 'CN Name', sn: 'User', uid: 'testdriveuser', mail: 'test@test.org', }; await dm.ldap.add(testDN, entry2); name = await drive.getDisplayNameFromDN(testDN); expect(name).to.equal('CN Name'); await dm.ldap.delete(testDN); // Test with givenName + sn fallback (cn present but displayName not) const entry3 = { objectClass: ['top', 'inetOrgPerson'], cn: 'Placeholder', sn: 'Doe', givenName: 'John', uid: 'testdriveuser', mail: 'test@test.org', }; await dm.ldap.add(testDN, entry3); // Since cn is present, it will be used as fallback (displayName -> cn -> givenName+sn) name = await drive.getDisplayNameFromDN(testDN); expect(name).to.equal('Placeholder'); }); it('getMailFromDN should return the mail attribute', async () => { const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'Mail User', sn: 'User', uid: 'testdriveuser', mail: 'mailuser@test.org', }; await dm.ldap.add(testDN, entry); const mail = await drive.getMailFromDN(testDN); expect(mail).to.equal('mailuser@test.org'); }); it('getDriveQuotaFromDN should return the drive quota in bytes', async () => { const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Quota User', sn: 'User', uid: 'testdriveuser', mail: 'quota@test.org', twakeDriveQuota: '5368709120', }; await dm.ldap.add(testDN, entry); const quota = await drive.getDriveQuotaFromDN(testDN); expect(quota).to.equal(5368709120); }); it('getDriveQuotaFromDN should return null if attribute is missing', async () => { const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'No Quota User', sn: 'User', uid: 'testdriveuser', mail: 'noquota@test.org', }; await dm.ldap.add(testDN, entry); const quota = await drive.getDriveQuotaFromDN(testDN); expect(quota).to.be.null; }); it('syncUserToCozy should manually sync user attributes', async () => { let apiCalled = false; const syncScope = nock( process.env.DM_TWAKE_DRIVE_WEBADMIN_URL || TWAKE_DRIVE_WEBADMIN_URL ) .patch('/instances/syncuser.twake.cloud') .query({ FromCloudery: 'true', Email: 'sync@test.org', PublicName: 'Sync User', DiskQuota: '1073741824', }) .reply(200, { success: true }); syncScope.on('request', () => { apiCalled = true; }); const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Sync User', sn: 'User', uid: 'testdriveuser', mail: 'sync@test.org', twakeCozyDomain: 'syncuser.twake.cloud', twakeDriveQuota: '1073741824', }; await dm.ldap.add(testDN, entry); const result = await drive.syncUserToCozy(testDN); expect(result).to.be.true; expect(apiCalled).to.be.true; }); it('syncUserToCozy should return false if user has no Cozy domain', async () => { const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'No Cozy', sn: 'User', uid: 'testdriveuser', mail: 'nocozy@test.org', }; await dm.ldap.add(testDN, entry); const result = await drive.syncUserToCozy(testDN); expect(result).to.be.false; }); it('syncUserToCozy should return false if user not found', async () => { const result = await drive.syncUserToCozy( `uid=nonexistent,${process.env.DM_LDAP_BASE}` ); expect(result).to.be.false; }); it('blockInstance should block a Cozy instance', async () => { let apiCalled = false; let blockedValue = ''; let reasonValue = ''; nock(process.env.DM_TWAKE_DRIVE_WEBADMIN_URL || TWAKE_DRIVE_WEBADMIN_URL) .patch('/instances/blockuser.mycozy.cloud') .query((query: ParsedUrlQuery) => { blockedValue = String(query.Blocked || ''); reasonValue = String(query.BlockingReason || ''); return query.FromCloudery === 'true' && query.Blocked === 'true'; }) .reply(200, function () { apiCalled = true; return { success: true }; }); const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Block User', sn: 'User', uid: 'testdriveuser', mail: 'block@test.org', twakeCozyDomain: 'blockuser.mycozy.cloud', }; await dm.ldap.add(testDN, entry); const result = await drive.blockInstance(testDN, 'PAYMENT_FAILED'); expect(result).to.be.true; expect(apiCalled).to.be.true; expect(blockedValue).to.equal('true'); expect(reasonValue).to.equal('PAYMENT_FAILED'); }); it('blockInstance should work without reason', async () => { let apiCalled = false; nock(process.env.DM_TWAKE_DRIVE_WEBADMIN_URL || TWAKE_DRIVE_WEBADMIN_URL) .patch('/instances/blockuser2.mycozy.cloud') .query((query: ParsedUrlQuery) => { return ( query.FromCloudery === 'true' && query.Blocked === 'true' && !query.BlockingReason ); }) .reply(200, function () { apiCalled = true; return { success: true }; }); const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Block User 2', sn: 'User', uid: 'testdriveuser', mail: 'block2@test.org', twakeCozyDomain: 'blockuser2.mycozy.cloud', }; await dm.ldap.add(testDN, entry); const result = await drive.blockInstance(testDN); expect(result).to.be.true; expect(apiCalled).to.be.true; }); it('blockInstance should return false if user has no Cozy domain', async () => { const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'No Cozy', sn: 'User', uid: 'testdriveuser', mail: 'nocozy@test.org', }; await dm.ldap.add(testDN, entry); const result = await drive.blockInstance(testDN); expect(result).to.be.false; }); it('unblockInstance should unblock a Cozy instance', async () => { let apiCalled = false; let blockedValue = ''; nock(process.env.DM_TWAKE_DRIVE_WEBADMIN_URL || TWAKE_DRIVE_WEBADMIN_URL) .patch('/instances/unblockuser.mycozy.cloud') .query((query: ParsedUrlQuery) => { blockedValue = String(query.Blocked || ''); return query.FromCloudery === 'true' && query.Blocked === 'false'; }) .reply(200, function () { apiCalled = true; return { success: true }; }); const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Unblock User', sn: 'User', uid: 'testdriveuser', mail: 'unblock@test.org', twakeCozyDomain: 'unblockuser.mycozy.cloud', }; await dm.ldap.add(testDN, entry); const result = await drive.unblockInstance(testDN); expect(result).to.be.true; expect(apiCalled).to.be.true; expect(blockedValue).to.equal('false'); }); it('unblockInstance should return false if user has no Cozy domain', async () => { const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'No Cozy', sn: 'User', uid: 'testdriveuser', mail: 'nocozy@test.org', }; await dm.ldap.add(testDN, entry); const result = await drive.unblockInstance(testDN); expect(result).to.be.false; }); }); describe('Security - Domain validation', () => { let testDNMalicious: string; beforeEach(async () => { testDNMalicious = `uid=malicioususer,${process.env.DM_LDAP_BASE}`; // Clean up any existing entry try { await dm.ldap.delete(testDNMalicious); } catch { // Entry may not exist, ignore } }); afterEach(async () => { nock.cleanAll(); try { await dm.ldap.delete(testDNMalicious); } catch { // Entry may not exist, ignore } }); it('should reject domain with path traversal attempt', async () => { // No nock mock - request should not be made const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Malicious User', sn: 'User', uid: 'malicioususer', mail: 'malicious@test.org', twakeCozyDomain: '../admin', }; await dm.ldap.add(testDNMalicious, entry); // syncUserToCozy should return true (it found the user) but no API call should be made const result = await drive.syncUserToCozy(testDNMalicious); // The method returns true because it found the entry, but the API call is skipped expect(result).to.be.true; // No pending mocks should exist (request was blocked) expect(nock.pendingMocks()).to.have.lengthOf(0); }); it('should reject domain with query string injection', async () => { const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Malicious User', sn: 'User', uid: 'malicioususer', mail: 'malicious@test.org', twakeCozyDomain: 'valid.com?admin=true', }; await dm.ldap.add(testDNMalicious, entry); const result = await drive.syncUserToCozy(testDNMalicious); expect(result).to.be.true; expect(nock.pendingMocks()).to.have.lengthOf(0); }); it('should reject domain with fragment injection', async () => { const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Malicious User', sn: 'User', uid: 'malicioususer', mail: 'malicious@test.org', twakeCozyDomain: 'valid.com#admin', }; await dm.ldap.add(testDNMalicious, entry); const result = await drive.syncUserToCozy(testDNMalicious); expect(result).to.be.true; expect(nock.pendingMocks()).to.have.lengthOf(0); }); it('should accept valid domain names', async () => { let apiCalled = false; nock(process.env.DM_TWAKE_DRIVE_WEBADMIN_URL || TWAKE_DRIVE_WEBADMIN_URL) .patch('/instances/user.company.mycozy.cloud') .query(() => true) .reply(200, function () { apiCalled = true; return { success: true }; }); const entry = { objectClass: ['top', 'inetOrgPerson', 'twakeWhitePages'], cn: 'Valid User', sn: 'User', uid: 'malicioususer', mail: 'valid@test.org', twakeCozyDomain: 'user.company.mycozy.cloud', }; await dm.ldap.add(testDNMalicious, entry); const result = await drive.syncUserToCozy(testDNMalicious); expect(result).to.be.true; expect(apiCalled).to.be.true; }); }); }); linagora-ldap-rest-16e557e/test/plugins/twake/james.test.ts000066400000000000000000001113361522642357000237550ustar00rootroot00000000000000import nock from 'nock'; import request from 'supertest'; import { DM } from '../../../src/bin'; import James from '../../../src/plugins/twake/james'; import { expect } from 'chai'; import OnLdapChange from '../../../src/plugins/ldap/onChange'; import { skipIfMissingEnvVars, LDAP_ENV_VARS } from '../../helpers/env'; import LdapGroups from '../../../src/plugins/ldap/groups'; describe('James Plugin', () => { let testDN: string; let testDNQuota: string; let testDNAliases: string; let testDNForwards: string; let dm: DM; let james: James; let ldapGroups: LdapGroups; let scope: nock.Scope; before(function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS]); // Initialize DNs after env vars are set testDN = `uid=testusermail,${process.env.DM_LDAP_BASE}`; testDNQuota = `uid=quotauser,${process.env.DM_LDAP_BASE}`; testDNAliases = `uid=aliasuser,${process.env.DM_LDAP_BASE}`; testDNForwards = `uid=forwarduser,${process.env.DM_LDAP_BASE}`; scope = nock(process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000') .persist() // Mail rename .post('/users/testmail@test.org/rename/t@t.org?action=rename&force') .reply(200, { success: true }) .post('/users/primary@test.org/rename/newprimary@test.org?action=rename&force') .reply(200, { success: true }) // Quota .put('/quota/users/testmail@test.org/size', '50000000') .reply(204) .put('/quota/users/testmail@test.org/size', '100000000') .reply(204) .put('/quota/users/quotauser@test.org/size', '75000000') .reply(204) // Alias creation on user add .put('/address/aliases/aliasuser@test.org/sources/alias1@test.org') .reply(204) .put('/address/aliases/aliasuser@test.org/sources/alias2@test.org') .reply(204) // Alias modification .put('/address/aliases/aliasuser@test.org/sources/alias3@test.org') .reply(204) .delete('/address/aliases/aliasuser@test.org/sources/alias1@test.org') .reply(204) // Aliases update on mail change .delete('/address/aliases/primary@test.org/sources/alias1@test.org') .reply(204) .delete('/address/aliases/primary@test.org/sources/alias2@test.org') .reply(204) .put('/address/aliases/newprimary@test.org/sources/alias1@test.org') .reply(204) .put('/address/aliases/newprimary@test.org/sources/alias2@test.org') .reply(204) // Identity - testmail@test.org .get('/users/testmail@test.org/identities') .reply(200, [ { id: 'testmail-identity-id', name: 'Test User', email: 'testmail@test.org', }, ]) .put('/users/testmail@test.org/identities/testmail-identity-id') .reply(200, { success: true }) // Identity - quotauser@test.org (created in quota test) .get('/users/quotauser@test.org/identities') .reply(200, [ { id: 'quotauser-identity-id', name: 'Quota User', email: 'quotauser@test.org', }, ]) .put('/users/quotauser@test.org/identities/quotauser-identity-id') .reply(200, { success: true }) // Identity - aliasuser@test.org (created in alias tests) .get('/users/aliasuser@test.org/identities') .reply(200, [ { id: 'aliasuser-identity-id', name: 'Alias User', email: 'aliasuser@test.org', }, ]) .put('/users/aliasuser@test.org/identities/aliasuser-identity-id') .reply(200, { success: true }) // Identity - primary@test.org (created in mail change test) .get('/users/primary@test.org/identities') .reply(200, [ { id: 'primary-identity-id', name: 'Primary User', email: 'primary@test.org', }, ]) .put('/users/primary@test.org/identities/primary-identity-id') .reply(200, { success: true }) // Identity - newprimary@test.org (after mail change) .get('/users/newprimary@test.org/identities') .reply(200, [ { id: 'newprimary-identity-id', name: 'Primary User', email: 'newprimary@test.org', }, ]) .put('/users/newprimary@test.org/identities/newprimary-identity-id') .reply(200, { success: true }) // Identity - t@t.org (after mail change in basic test) .get('/users/t@t.org/identities') .reply(200, [ { id: 't-identity-id', name: 'Test User', email: 't@t.org', }, ]) .put('/users/t@t.org/identities/t-identity-id') .reply(200, { success: true }) // Identity - forward@test.org (created in forward tests) .get('/users/forward@test.org/identities') .reply(200, [ { id: 'forward-identity-id', name: 'Forward User', email: 'forward@test.org', }, ]) .put('/users/forward@test.org/identities/forward-identity-id') .reply(200, { success: true }) // Identity - delegate@test.org (created in delegation tests) .get('/users/delegate@test.org/identities') .reply(200, [ { id: 'delegate-identity-id', name: 'Delegate User', email: 'delegate@test.org', }, ]) .put('/users/delegate@test.org/identities/delegate-identity-id') .reply(200, { success: true }) // Identity - assistant@test.org (created in delegation tests) .get('/users/assistant@test.org/identities') .reply(200, [ { id: 'assistant-identity-id', name: 'Assistant User', email: 'assistant@test.org', }, ]) .put('/users/assistant@test.org/identities/assistant-identity-id') .reply(200, { success: true }) // Identity - assistant1@test.org (created in delegation tests) .get('/users/assistant1@test.org/identities') .reply(200, [ { id: 'assistant1-identity-id', name: 'Assistant 1', email: 'assistant1@test.org', }, ]) .put('/users/assistant1@test.org/identities/assistant1-identity-id') .reply(200, { success: true }) // Identity - assistant2@test.org (created in delegation tests) .get('/users/assistant2@test.org/identities') .reply(200, [ { id: 'assistant2-identity-id', name: 'Assistant 2', email: 'assistant2@test.org', }, ]) .put('/users/assistant2@test.org/identities/assistant2-identity-id') .reply(200, { success: true }) // Quota API - GET user quota .get('/quota/users/quotauser@test.org') .reply(200, { 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, }, }, }) .get('/quota/users/testmail@test.org') .reply(200, { global: null, domain: null, user: { count: null, size: 100000000 }, computed: { count: null, size: 100000000 }, occupation: { size: 10000000, count: 100, ratio: { size: 0.1, count: null, max: 0.1, }, }, }) .get('/quota/users/nonexistent@test.org') .reply(404); nock.disableNetConnect(); }); after(function () { if (scope) { scope.persist(false); } nock.cleanAll(); nock.enableNetConnect(); }); beforeEach(async () => { dm = new DM(); dm.config.delegation_attribute = 'twakeDelegatedUsers'; dm.config.james_init_delay = 0; // No delay in tests await dm.ready; james = new James(dm); ldapGroups = new LdapGroups(dm); await dm.registerPlugin('onLdapChange', new OnLdapChange(dm)); await dm.registerPlugin('ldapGroups', ldapGroups); await dm.registerPlugin('james', james); }); afterEach(async () => { // Clean up: delete the test entries if they exist try { await dm.ldap.delete(testDN); } catch (err) { // Ignore errors if the entry does not exist } try { await dm.ldap.delete(testDNQuota); } catch (err) { // Ignore errors if the entry does not exist } try { await dm.ldap.delete(testDNAliases); } catch (err) { // Ignore errors if the entry does not exist } try { await dm.ldap.delete(testDNForwards); } catch (err) { // Ignore errors if the entry does not exist } }); it("should try to rename mailbox via James's webadmin", async () => { const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'Test User', sn: 'User', uid: 'testusermail', mail: 'testmail@test.org', }; let res = await dm.ldap.add(testDN, entry); expect(res).to.be.true; res = await dm.ldap.modify(testDN, { replace: { mail: 't@t.org' }, }); expect(res).to.be.true; }); describe('Quota management', () => { it('should initialize quota when user is created', async () => { const entry = { objectClass: ['top', 'twakeAccount'], uid: 'quotauser', mail: 'quotauser@test.org', mailQuotaSize: '75000000', }; const res = await dm.ldap.add(testDNQuota, entry); expect(res).to.be.true; // Wait for ldapadddone hook to execute await new Promise(resolve => setTimeout(resolve, 100)); }); it('should update quota when modified in LDAP', async () => { const entry = { objectClass: ['top', 'twakeAccount'], uid: 'testusermail', mail: 'testmail@test.org', mailQuotaSize: '50000000', }; let res = await dm.ldap.add(testDN, entry); expect(res).to.be.true; // Modify quota res = await dm.ldap.modify(testDN, { replace: { mailQuotaSize: '100000000' }, }); expect(res).to.be.true; }); }); describe('Alias management', () => { it('should create aliases when user is added with mailAlternateAddress', async () => { const entry = { objectClass: ['top', 'twakeAccount'], uid: 'aliasuser', mail: 'aliasuser@test.org', mailAlternateAddress: ['alias1@test.org', 'alias2@test.org'], }; const res = await dm.ldap.add(testDNAliases, entry); expect(res).to.be.true; // Wait for ldapadddone hook to execute await new Promise(resolve => setTimeout(resolve, 100)); }); it('should add and remove aliases when mailAlternateAddress is modified', async () => { // Create user with initial aliases const entry = { objectClass: ['top', 'twakeAccount'], uid: 'aliasuser', mail: 'aliasuser@test.org', mailAlternateAddress: ['alias1@test.org', 'alias2@test.org'], }; let res = await dm.ldap.add(testDNAliases, entry); expect(res).to.be.true; // Wait for initial aliases to be created await new Promise(resolve => setTimeout(resolve, 1200)); // Modify aliases: remove alias1, keep alias2, add alias3 res = await dm.ldap.modify(testDNAliases, { replace: { mailAlternateAddress: ['alias2@test.org', 'alias3@test.org'], }, }); expect(res).to.be.true; // Wait for alias changes to be applied await new Promise(resolve => setTimeout(resolve, 50)); }); it('should update all aliases when primary mail changes', async () => { // Create user with mail and aliases const entry = { objectClass: ['top', 'twakeAccount'], uid: 'aliasuser', mail: 'primary@test.org', mailAlternateAddress: ['alias1@test.org', 'alias2@test.org'], }; let res = await dm.ldap.add(testDNAliases, entry); expect(res).to.be.true; // Wait for initial aliases to be created await new Promise(resolve => setTimeout(resolve, 1200)); // Change primary mail - aliases should be updated to point to new mail res = await dm.ldap.modify(testDNAliases, { replace: { mail: 'newprimary@test.org' }, }); expect(res).to.be.true; // Wait for aliases to be updated await new Promise(resolve => setTimeout(resolve, 50)); }); it('should rename mailbox when mail changes without aliases', async () => { // Create user without aliases const testDNNoAlias = `uid=noaliasuser,${process.env.DM_LDAP_BASE}`; const entry = { objectClass: ['top', 'inetOrgPerson'], uid: 'noaliasuser', mail: 'noalias@test.org', cn: 'No Alias User', sn: 'User', }; // Add rename mock for this test const renameScope = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .post('/users/noalias@test.org/rename/newalias@test.org?action=rename&force') .reply(200, { success: true }) .get('/users/noalias@test.org/identities') .reply(200, [ { id: 'noalias-identity-id', name: 'No Alias User', email: 'noalias@test.org', }, ]) .put('/users/noalias@test.org/identities/noalias-identity-id') .reply(200, { success: true }) .get('/users/newalias@test.org/identities') .reply(200, [ { id: 'newalias-identity-id', name: 'No Alias User', email: 'newalias@test.org', }, ]) .put('/users/newalias@test.org/identities/newalias-identity-id') .reply(200, { success: true }); try { let res = await dm.ldap.add(testDNNoAlias, entry); expect(res).to.be.true; // Wait for creation await new Promise(resolve => setTimeout(resolve, 1200)); // Change mail - should only rename, no aliases to update res = await dm.ldap.modify(testDNNoAlias, { replace: { mail: 'newalias@test.org' }, }); expect(res).to.be.true; // Wait for rename await new Promise(resolve => setTimeout(resolve, 50)); // Verify the rename endpoint was called expect(renameScope.isDone()).to.be.false; // nock persist mode } finally { // Cleanup try { await dm.ldap.delete(testDNNoAlias); } catch (err) { // Ignore } renameScope.persist(false); } }); it('should rename mailbox when mail changes without aliases', async () => { // Create user without aliases const testDNNoAlias = `uid=noaliasuser,${process.env.DM_LDAP_BASE}`; const entry = { objectClass: ['top', 'inetOrgPerson'], uid: 'noaliasuser', mail: 'noalias@test.org', cn: 'No Alias User', sn: 'User', }; // Add rename mock for this test const renameScope = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .post('/users/noalias@test.org/rename/newalias@test.org?action=rename&force') .reply(200, { success: true }) .get('/users/noalias@test.org/identities') .reply(200, [ { id: 'noalias-identity-id', name: 'No Alias User', email: 'noalias@test.org', }, ]) .put('/users/noalias@test.org/identities/noalias-identity-id') .reply(200, { success: true }) .get('/users/newalias@test.org/identities') .reply(200, [ { id: 'newalias-identity-id', name: 'No Alias User', email: 'newalias@test.org', }, ]) .put('/users/newalias@test.org/identities/newalias-identity-id') .reply(200, { success: true }); try { let res = await dm.ldap.add(testDNNoAlias, entry); expect(res).to.be.true; // Wait for creation await new Promise(resolve => setTimeout(resolve, 1200)); // Change mail - should only rename, no aliases to update res = await dm.ldap.modify(testDNNoAlias, { replace: { mail: 'newalias@test.org' }, }); expect(res).to.be.true; // Wait for rename await new Promise(resolve => setTimeout(resolve, 500)); // Verify the rename endpoint was called expect(renameScope.isDone()).to.be.false; // nock persist mode } finally { // Cleanup try { await dm.ldap.delete(testDNNoAlias); } catch (err) { // Ignore } renameScope.persist(false); } }); }); describe('Forward management', () => { it('should add forwards when mailForwardingAddress is added', async () => { const forwardScope1 = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .put('/address/forwards/forward@test.org/targets/manager@test.org') .reply(204); const forwardScope2 = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .put('/address/forwards/forward@test.org/targets/boss@test.org') .reply(204); // Create entry without forwards first const entry = { objectClass: ['top', 'twakeAccount'], uid: 'forwarduser', mail: 'forward@test.org', }; let res = await dm.ldap.add(testDNForwards, entry); expect(res).to.be.true; // Add forwards via modify operation res = await dm.ldap.modify(testDNForwards, { add: { mailForwardingAddress: ['manager@test.org', 'boss@test.org'], }, }); expect(res).to.be.true; // Wait for onChange hook to execute (needs more time with real LDAP) await new Promise(resolve => setTimeout(resolve, 150)); // Verify the HTTP calls were made expect(forwardScope1.isDone()).to.be.true; expect(forwardScope2.isDone()).to.be.true; }); it('should add and remove forwards when mailForwardingAddress is modified', async () => { // Scopes for initial forwards const initialScope1 = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .put('/address/forwards/forward@test.org/targets/manager@test.org') .reply(204); const initialScope2 = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .put('/address/forwards/forward@test.org/targets/boss@test.org') .reply(204); // Create user without forwards const entry = { objectClass: ['top', 'twakeAccount'], uid: 'forwarduser', mail: 'forward@test.org', }; let res = await dm.ldap.add(testDNForwards, entry); expect(res).to.be.true; // Add initial forwards via modify res = await dm.ldap.modify(testDNForwards, { add: { mailForwardingAddress: ['manager@test.org', 'boss@test.org'], }, }); expect(res).to.be.true; // Wait for initial forwards to be created (needs more time with real LDAP) await new Promise(resolve => setTimeout(resolve, 150)); expect(initialScope1.isDone()).to.be.true; expect(initialScope2.isDone()).to.be.true; // Scopes for modification: delete manager, add assistant const deleteScope = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .delete('/address/forwards/forward@test.org/targets/manager@test.org') .reply(204); const addScope = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .put('/address/forwards/forward@test.org/targets/assistant@test.org') .reply(204); // Modify forwards: remove manager, keep boss, add assistant res = await dm.ldap.modify(testDNForwards, { replace: { mailForwardingAddress: ['boss@test.org', 'assistant@test.org'], }, }); expect(res).to.be.true; // Wait for forward changes to be applied (needs more time with real LDAP) await new Promise(resolve => setTimeout(resolve, 150)); // Verify the HTTP calls were made expect(deleteScope.isDone()).to.be.true; expect(addScope.isDone()).to.be.true; }); }); describe('Delegation', () => { let userDN: string; let assistantDN: string; let assistant1DN: string; let assistant2DN: string; before(() => { userDN = `uid=testdelegate,${process.env.DM_LDAP_BASE}`; assistantDN = `uid=assistant,${process.env.DM_LDAP_BASE}`; assistant1DN = `uid=assistant1,${process.env.DM_LDAP_BASE}`; assistant2DN = `uid=assistant2,${process.env.DM_LDAP_BASE}`; }); beforeEach(async () => { // Create assistant user try { await dm.ldap.add(assistantDN, { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], cn: 'Assistant', sn: 'Assistant', uid: 'assistant', mail: 'assistant@test.org', }); } catch (err) { // Ignore if already exists } }); afterEach(async () => { try { await dm.ldap.delete(userDN); } catch (err) { // Ignore } try { await dm.ldap.delete(assistantDN); } catch (err) { // Ignore } try { await dm.ldap.delete(assistant1DN); } catch (err) { // Ignore } try { await dm.ldap.delete(assistant2DN); } catch (err) { // Ignore } }); it('should add delegation when twakeDelegatedUsers is added', async () => { let apiCalled = false; const addScope = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .put('/users/delegate@test.org/authorizedUsers/assistant@test.org') .reply(200); addScope.on('request', () => { apiCalled = true; }); const entry = { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], cn: 'Test Delegate', sn: 'Delegate', uid: 'testdelegate', mail: 'delegate@test.org', }; await dm.ldap.add(userDN, entry); await dm.ldap.modify(userDN, { add: { twakeDelegatedUsers: assistantDN }, }); // Wait for onChange hook to execute (needs more time with real LDAP) await new Promise(resolve => setTimeout(resolve, 150)); expect(apiCalled).to.be.true; }); it('should remove delegation when twakeDelegatedUsers is removed', async () => { let addApiCalled = false; let removeApiCalled = false; const addScope = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .put('/users/delegate@test.org/authorizedUsers/assistant@test.org') .reply(200); const removeScope = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .delete('/users/delegate@test.org/authorizedUsers/assistant@test.org') .reply(200); addScope.on('request', () => { addApiCalled = true; }); removeScope.on('request', () => { removeApiCalled = true; }); // First create user without delegation const entry = { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], cn: 'Test Delegate', sn: 'Delegate', uid: 'testdelegate', mail: 'delegate@test.org', }; await dm.ldap.add(userDN, entry); // Add delegation await dm.ldap.modify(userDN, { add: { twakeDelegatedUsers: assistantDN }, }); // Wait for add hook (needs more time with real LDAP) await new Promise(resolve => setTimeout(resolve, 150)); // Now remove delegation await dm.ldap.modify(userDN, { delete: { twakeDelegatedUsers: assistantDN }, }); // Wait for remove hook (needs more time with real LDAP) await new Promise(resolve => setTimeout(resolve, 150)); expect(removeApiCalled).to.be.true; }); it('should handle multiple delegated users', async () => { // Create additional assistants await dm.ldap.add(assistant1DN, { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], cn: 'Assistant 1', sn: 'Assistant', uid: 'assistant1', mail: 'assistant1@test.org', }); await dm.ldap.add(assistant2DN, { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], cn: 'Assistant 2', sn: 'Assistant', uid: 'assistant2', mail: 'assistant2@test.org', }); const multiAddScope1 = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .put('/users/delegate@test.org/authorizedUsers/assistant1@test.org') .reply(200); const multiAddScope2 = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .put('/users/delegate@test.org/authorizedUsers/assistant2@test.org') .reply(200); const entry = { objectClass: ['top', 'twakeAccount', 'twakeWhitePages'], cn: 'Test Delegate', sn: 'Delegate', uid: 'testdelegate', mail: 'delegate@test.org', }; await dm.ldap.add(userDN, entry); await dm.ldap.modify(userDN, { add: { twakeDelegatedUsers: [assistant1DN, assistant2DN], }, }); // Wait for onChange hook to execute (needs more time with real LDAP) await new Promise(resolve => setTimeout(resolve, 150)); expect(multiAddScope1.isDone()).to.be.true; expect(multiAddScope2.isDone()).to.be.true; }); }); describe('Identity synchronization', () => { const timestamp = Date.now(); const testUser1 = `testidentity${timestamp}`; const testUser2 = `newuser${timestamp}`; let testDN2: string; let testDN3: string; before(() => { testDN2 = `uid=${testUser1},${process.env.DM_LDAP_BASE}`; testDN3 = `uid=${testUser2},${process.env.DM_LDAP_BASE}`; }); const testMail1 = `${testUser1}@test.org`; const testMail2 = `${testUser2}@test.org`; let identityScope: nock.Scope; before(function () { // Mock JMAP identity endpoints identityScope = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .persist() .get(`/users/${testMail1}/identities`) .reply(200, [ { id: 'identity-id-1', name: 'Old Name', email: testMail1, }, ]) .put(`/users/${testMail1}/identities/identity-id-1`) .reply(200, { success: true }) .get(`/users/${testMail2}/identities`) .reply(200, [ { id: 'newuser-identity-id', name: '', email: testMail2, }, ]) .put(`/users/${testMail2}/identities/newuser-identity-id`) .reply(200, { success: true }); }); afterEach(async () => { // Clean up: delete the test entries if they exist try { await dm.ldap.delete(testDN2); } catch (err) { // Ignore errors if the entry does not exist } try { await dm.ldap.delete(testDN3); } catch (err) { // Ignore errors if the entry does not exist } }); it('should update James identity when displayName changes', async () => { const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'Identity Test', sn: 'Test', uid: testUser1, mail: testMail1, displayName: 'Old Name', }; let res = await dm.ldap.add(testDN2, entry); expect(res).to.be.true; // Modify displayName res = await dm.ldap.modify(testDN2, { replace: { displayName: 'New Display Name' }, }); expect(res).to.be.true; }); it('should update James identity when cn changes', async () => { const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'Old CN', sn: 'Test', uid: testUser1, mail: testMail1, }; let res = await dm.ldap.add(testDN2, entry); expect(res).to.be.true; // Modify cn res = await dm.ldap.modify(testDN2, { replace: { cn: 'New CN' }, }); expect(res).to.be.true; }); it('should use cn as fallback when displayName is not present', async () => { const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'John Doe', sn: 'Doe', uid: testUser1, mail: testMail1, }; let res = await dm.ldap.add(testDN2, entry); expect(res).to.be.true; // Test getDisplayNameFromDN method directly before modify let displayName = await james.getDisplayNameFromDN(testDN2); expect(displayName).to.equal('John Doe'); // Modify cn - this should trigger identity update using cn res = await dm.ldap.modify(testDN2, { replace: { cn: 'Jane Doe' }, }); expect(res).to.be.true; // Test getDisplayNameFromDN method again after modify displayName = await james.getDisplayNameFromDN(testDN2); expect(displayName).to.equal('Jane Doe'); }); it('should initialize James identity when user is created', async () => { const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'New User', sn: 'User', uid: testUser2, mail: testMail2, displayName: 'New User Display', }; let res = await dm.ldap.add(testDN3, entry); expect(res).to.be.true; // Wait for the ldapadddone hook to execute (with 1s delay in hook) await new Promise(resolve => setTimeout(resolve, 1200)); }); }); describe('Signature template', () => { let testDN4: string; let signatureScope: nock.Scope; let savedTemplate: string | undefined; before(function () { testDN4 = `uid=testsignature,${process.env.DM_LDAP_BASE}`; // Save current template and set test template savedTemplate = process.env.DM_JAMES_SIGNATURE_TEMPLATE; process.env.DM_JAMES_SIGNATURE_TEMPLATE = '--
{givenName} {sn}
{title}
{departmentNumber}'; // Mock JMAP identity endpoints for signature test signatureScope = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .persist() .get('/users/signature@test.org/identities') .reply(200, [ { id: 'signature-identity-id', name: 'John Doe', email: 'signature@test.org', }, ]) .put('/users/signature@test.org/identities/signature-identity-id') .reply(200, { success: true }); }); after(function () { // Restore original template if (savedTemplate !== undefined) { process.env.DM_JAMES_SIGNATURE_TEMPLATE = savedTemplate; } else { delete process.env.DM_JAMES_SIGNATURE_TEMPLATE; } }); afterEach(async () => { try { await dm.ldap.delete(testDN4); } catch (err) { // Ignore errors if the entry does not exist } }); it('should generate signature from template and LDAP attributes', async () => { const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'John Doe', sn: 'Doe', givenName: 'John', uid: 'testsignature', mail: 'signature@test.org', title: 'Software Engineer', departmentNumber: 'IT Department', displayName: 'John Doe', }; let res = await dm.ldap.add(testDN4, entry); expect(res).to.be.true; // Test signature generation directly const signature = await james.generateSignature(testDN4); expect(signature).to.equal( '--
John Doe
Software Engineer
IT Department' ); }); it('should update James identity with signature on user modification', async () => { const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'John Doe', sn: 'Doe', givenName: 'John', uid: 'testsignature', mail: 'signature@test.org', title: 'Software Engineer', departmentNumber: 'IT', displayName: 'John Doe', }; let res = await dm.ldap.add(testDN4, entry); expect(res).to.be.true; // Modify displayName to trigger identity update res = await dm.ldap.modify(testDN4, { replace: { displayName: 'John M. Doe' }, }); expect(res).to.be.true; // Wait for hook to execute await new Promise(resolve => setTimeout(resolve, 50)); }); it('should handle missing attributes in template gracefully', async () => { const entry = { objectClass: ['top', 'inetOrgPerson'], cn: 'Jane Smith', sn: 'Smith', givenName: 'Jane', uid: 'testsignature', mail: 'signature@test.org', displayName: 'Jane Smith', // Note: title and departmentNumber are missing }; let res = await dm.ldap.add(testDN4, entry); expect(res).to.be.true; // Test signature generation with missing attributes const signature = await james.generateSignature(testDN4); expect(signature).to.equal('--
Jane Smith

'); }); }); describe('Quota Usage API', () => { // Enable localhost connections for API tests before(() => { nock.enableNetConnect('127.0.0.1'); }); after(() => { nock.disableNetConnect(); }); it('should return quota usage and limits for an existing user', async () => { // Create user const entry = { objectClass: ['top', 'twakeAccount'], uid: 'quotauser', mail: 'quotauser@test.org', mailQuotaSize: '75000000', }; await dm.ldap.add(testDNQuota, entry); // Get quota usage via API const res = await request(dm.app) .get('/api/v1/users/quotauser/quota-usage') .expect(200); expect(res.body).to.have.property('global'); expect(res.body).to.have.property('domain'); expect(res.body).to.have.property('user'); expect(res.body).to.have.property('computed'); expect(res.body).to.have.property('occupation'); expect(res.body.occupation).to.have.property('size'); expect(res.body.occupation).to.have.property('count'); expect(res.body.occupation).to.have.property('ratio'); expect(res.body.occupation.size).to.equal(50000000); expect(res.body.occupation.count).to.equal(250); }); it('should return 404 for non-existent user', async () => { const res = await request(dm.app) .get('/api/v1/users/nonexistentuser/quota-usage') .expect(404); expect(res.body).to.have.property('error'); expect(res.body.error).to.match(/not found/i); }); it('should return quota usage for user with null limits', async () => { // Create user const entry = { objectClass: ['top', 'twakeAccount'], uid: 'testusermail', mail: 'testmail@test.org', mailQuotaSize: '100000000', }; await dm.ldap.add(testDN, entry); // Get quota usage via API const res = await request(dm.app) .get('/api/v1/users/testusermail/quota-usage') .expect(200); expect(res.body.occupation.size).to.equal(10000000); expect(res.body.user.size).to.equal(100000000); }); it('should prevent LDAP injection attacks', async () => { // Attempt LDAP injection with wildcards and special characters const res = await request(dm.app) .get('/api/v1/users/*/quota-usage') .expect(404); expect(res.body).to.have.property('error'); expect(res.body.error).to.match(/not found/i); }); }); describe('deleteUserData', () => { it('should call POST /users/{mail}?action=deleteData and return taskId', async () => { const deleteDataScope = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .post('/users/user@test.org?action=deleteData') .reply(201, { taskId: 'task-123' }); const result = await james.deleteUserData('user@test.org'); expect(result).to.deep.equal({ taskId: 'task-123' }); expect(deleteDataScope.isDone()).to.be.true; }); it('should include fromStep parameter when provided', async () => { const deleteDataScope = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .post( '/users/user@test.org?action=deleteData&fromStep=DeleteMailboxesStep' ) .reply(201, { taskId: 'task-456' }); const result = await james.deleteUserData( 'user@test.org', 'DeleteMailboxesStep' ); expect(result).to.deep.equal({ taskId: 'task-456' }); expect(deleteDataScope.isDone()).to.be.true; }); it('should return null and log error on non-OK response', async () => { const deleteDataScope = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .post('/users/baduser@test.org?action=deleteData') .reply(400, { error: 'Bad request' }); const result = await james.deleteUserData('baduser@test.org'); expect(result).to.be.null; expect(deleteDataScope.isDone()).to.be.true; }); }); }); linagora-ldap-rest-16e557e/test/plugins/twake/jamesBranchValidation.test.ts000066400000000000000000000425071522642357000271110ustar00rootroot00000000000000import nock from 'nock'; import { DM } from '../../../src/bin'; import James from '../../../src/plugins/twake/james'; import { expect } from 'chai'; import OnLdapChange from '../../../src/plugins/ldap/onChange'; import { skipIfMissingEnvVars, LDAP_ENV_VARS } from '../../helpers/env'; import LdapGroups from '../../../src/plugins/ldap/groups'; describe('James Branch Validation', () => { const timestamp = Date.now(); let userBase: string; let groupBase: string; let listsBase: string; let teamsBase: string; let nomenclatureBase: string; let mailboxTypeBase: string; let dm: DM; let james: James; let ldapGroups: LdapGroups; let scope: nock.Scope; before(async function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS]); // Initialize DNs after env vars are set userBase = `ou=users,${process.env.DM_LDAP_BASE}`; groupBase = process.env.DM_LDAP_GROUP_BASE || `ou=groups,${process.env.DM_LDAP_BASE}`; listsBase = `ou=lists,${groupBase}`; teamsBase = `ou=teams,${groupBase}`; nomenclatureBase = `ou=nomenclature,${process.env.DM_LDAP_BASE}`; mailboxTypeBase = `ou=twakeMailboxType,${nomenclatureBase}`; // Create DM instance with branch restrictions dm = new DM(); dm.config.delegation_attribute = 'twakeDelegatedUsers'; dm.config.james_mailing_list_branch = [listsBase]; dm.config.james_init_delay = 0; // No delay in tests await dm.ready; james = new James(dm); ldapGroups = new LdapGroups(dm); await dm.registerPlugin('onLdapChange', new OnLdapChange(dm)); await dm.registerPlugin('ldapGroups', ldapGroups); await dm.registerPlugin('james', james); // Mock James API calls scope = nock(process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000') .persist() // Mock identity sync .get(/\/users\/.*@test\.org\/identities$/) .reply(200, uri => { const email = uri.replace('/users/', '').replace('/identities', ''); return [ { id: `${email}-identity-id`, name: 'Test User', email: email, }, ]; }) .put(/\/users\/.*@test\.org\/identities\/.*-identity-id$/) .reply(200, { success: true }) // Mailing list operations .put(/\/address\/groups\/.*@test\.org\/.*@test\.org$/) .reply(204) .delete(/\/address\/groups\/.*@test\.org$/) .reply(204) // Team mailbox operations .put(/\/domains\/test\.org\/team-mailboxes\/.*@test\.org$/) .reply(204) .put( /\/domains\/test\.org\/team-mailboxes\/.*@test\.org\/members\/.*@test\.org$/ ) .reply(204); }); after(function () { if (scope) { scope.persist(false); } nock.cleanAll(); }); beforeEach(async function () { this.timeout(10000); // Ensure required OUs exist try { await dm.ldap.add(userBase, { objectClass: ['organizationalUnit', 'top'], ou: 'users', }); } catch (err) { // Ignore if already exists } try { await dm.ldap.add(listsBase, { objectClass: ['organizationalUnit', 'top'], ou: 'lists', }); } catch (err) { // Ignore if already exists } try { await dm.ldap.add(teamsBase, { objectClass: ['organizationalUnit', 'top'], ou: 'teams', }); } catch (err) { // Ignore if already exists } }); it('should allow mailing list in allowed branch (ou=lists)', async function () { this.timeout(10000); const testGroupDN = `cn=validlist-${timestamp},${listsBase}`; const testUserDN = `uid=vluser1-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'VL User 1', sn: 'User1', uid: `vluser1-${timestamp}`, mail: `vluser1-${timestamp}@test.org`, }); // Create mailing list in allowed branch await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `validlist-${timestamp}`, mail: `validlist-${timestamp}@test.org`, twakeMailboxType: `cn=mailingList,${mailboxTypeBase}`, member: testUserDN, twakeDepartmentLink: listsBase, twakeDepartmentPath: 'Lists', }); await new Promise(resolve => setTimeout(resolve, 50)); // Should succeed - mailing list created expect(true).to.be.true; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore } try { await dm.ldap.delete(testUserDN); } catch (err) { // Ignore } } }); it('should reject mailing list outside allowed branch', async function () { this.timeout(10000); const testGroupDN = `cn=invalidlist-${timestamp},${teamsBase}`; const testUserDN = `uid=iluser1-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'IL User 1', sn: 'User1', uid: `iluser1-${timestamp}`, mail: `iluser1-${timestamp}@test.org`, }); // Create mailing list in WRONG branch (should fail validation) await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `invalidlist-${timestamp}`, mail: `invalidlist-${timestamp}@test.org`, twakeMailboxType: `cn=mailingList,${mailboxTypeBase}`, member: testUserDN, twakeDepartmentLink: teamsBase, twakeDepartmentPath: 'Teams', }); await new Promise(resolve => setTimeout(resolve, 50)); // Mailing list should NOT be created in James (validation failed) // The LDAP entry will exist, but James won't have it expect(true).to.be.true; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore } try { await dm.ldap.delete(testUserDN); } catch (err) { // Ignore } } }); it('should allow team mailbox outside mailing list branch', async function () { this.timeout(10000); const testGroupDN = `cn=validteam-${timestamp},${teamsBase}`; const testUserDN = `uid=vtuser1-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'VT User 1', sn: 'User1', uid: `vtuser1-${timestamp}`, mail: `vtuser1-${timestamp}@test.org`, }); // Create team mailbox outside mailing list branch (should succeed) await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `validteam-${timestamp}`, mail: `validteam-${timestamp}@test.org`, twakeMailboxType: `cn=teamMailbox,${mailboxTypeBase}`, member: testUserDN, twakeDepartmentLink: teamsBase, twakeDepartmentPath: 'Teams', }); await new Promise(resolve => setTimeout(resolve, 50)); // Should succeed - team mailbox created expect(true).to.be.true; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore } try { await dm.ldap.delete(testUserDN); } catch (err) { // Ignore } } }); it('should reject team mailbox inside mailing list branch', async function () { this.timeout(10000); const testGroupDN = `cn=invalidteam-${timestamp},${listsBase}`; const testUserDN = `uid=ituser1-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'IT User 1', sn: 'User1', uid: `ituser1-${timestamp}`, mail: `ituser1-${timestamp}@test.org`, }); // Create team mailbox in mailing list branch (should fail validation) await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `invalidteam-${timestamp}`, mail: `invalidteam-${timestamp}@test.org`, twakeMailboxType: `cn=teamMailbox,${mailboxTypeBase}`, member: testUserDN, twakeDepartmentLink: listsBase, twakeDepartmentPath: 'Lists', }); await new Promise(resolve => setTimeout(resolve, 50)); // Team mailbox should NOT be created in James (validation failed) // The LDAP entry will exist, but James won't have it expect(true).to.be.true; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore } try { await dm.ldap.delete(testUserDN); } catch (err) { // Ignore } } }); it('should reject transition to mailing list outside allowed branch', async function () { this.timeout(10000); const testGroupDN = `cn=translist-${timestamp},${teamsBase}`; const testUserDN = `uid=tluser1-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'TL User 1', sn: 'User1', uid: `tluser1-${timestamp}`, mail: `tluser1-${timestamp}@test.org`, }); // Create simple group await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `translist-${timestamp}`, mail: `translist-${timestamp}@test.org`, twakeMailboxType: `cn=group,${mailboxTypeBase}`, member: testUserDN, twakeDepartmentLink: teamsBase, twakeDepartmentPath: 'Teams', }); await new Promise(resolve => setTimeout(resolve, 50)); // Try to transition to mailing list (should fail - wrong branch) await dm.ldap.modify(testGroupDN, { replace: { twakeMailboxType: `cn=mailingList,${mailboxTypeBase}`, }, }); await new Promise(resolve => setTimeout(resolve, 50)); // Transition should be rejected expect(true).to.be.true; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore } try { await dm.ldap.delete(testUserDN); } catch (err) { // Ignore } } }); it('should reject transition to team mailbox inside mailing list branch', async function () { this.timeout(10000); const testGroupDN = `cn=transteam-${timestamp},${listsBase}`; const testUserDN = `uid=ttuser1-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'TT User 1', sn: 'User1', uid: `ttuser1-${timestamp}`, mail: `ttuser1-${timestamp}@test.org`, }); // Create simple group in lists branch await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `transteam-${timestamp}`, mail: `transteam-${timestamp}@test.org`, twakeMailboxType: `cn=group,${mailboxTypeBase}`, member: testUserDN, twakeDepartmentLink: listsBase, twakeDepartmentPath: 'Lists', }); await new Promise(resolve => setTimeout(resolve, 50)); // Try to transition to team mailbox (should fail - in mailing list branch) await dm.ldap.modify(testGroupDN, { replace: { twakeMailboxType: `cn=teamMailbox,${mailboxTypeBase}`, }, }); await new Promise(resolve => setTimeout(resolve, 50)); // Transition should be rejected expect(true).to.be.true; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore } try { await dm.ldap.delete(testUserDN); } catch (err) { // Ignore } } }); }); describe('James Branch Validation - Unrestricted Configuration', () => { const timestamp = Date.now(); let userBase: string; let groupBase: string; let teamsBase: string; let nomenclatureBase: string; let mailboxTypeBase: string; let dm: DM; let james: James; let ldapGroups: LdapGroups; let scope: nock.Scope; before(async function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS]); // Initialize DNs after env vars are set userBase = `ou=users,${process.env.DM_LDAP_BASE}`; groupBase = process.env.DM_LDAP_GROUP_BASE || `ou=groups,${process.env.DM_LDAP_BASE}`; teamsBase = `ou=teams,${groupBase}`; nomenclatureBase = `ou=nomenclature,${process.env.DM_LDAP_BASE}`; mailboxTypeBase = `ou=twakeMailboxType,${nomenclatureBase}`; // Create DM instance WITHOUT branch restrictions dm = new DM(); dm.config.delegation_attribute = 'twakeDelegatedUsers'; dm.config.james_mailing_list_branch = []; // Empty = no restrictions dm.config.james_init_delay = 0; // No delay in tests await dm.ready; james = new James(dm); ldapGroups = new LdapGroups(dm); await dm.registerPlugin('onLdapChange', new OnLdapChange(dm)); await dm.registerPlugin('ldapGroups', ldapGroups); await dm.registerPlugin('james', james); // Mock James API calls scope = nock(process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000') .persist() // Mock identity sync .get(/\/users\/.*@test\.org\/identities$/) .reply(200, uri => { const email = uri.replace('/users/', '').replace('/identities', ''); return [ { id: `${email}-identity-id`, name: 'Test User', email: email, }, ]; }) .put(/\/users\/.*@test\.org\/identities\/.*-identity-id$/) .reply(200, { success: true }) // Mailing list operations .put(/\/address\/groups\/.*@test\.org\/.*@test\.org$/) .reply(204) // Team mailbox operations .put(/\/domains\/test\.org\/team-mailboxes\/.*@test\.org$/) .reply(204) .put( /\/domains\/test\.org\/team-mailboxes\/.*@test\.org\/members\/.*@test\.org$/ ) .reply(204); }); after(function () { if (scope) { scope.persist(false); } nock.cleanAll(); }); beforeEach(async function () { this.timeout(10000); // Ensure required OUs exist try { await dm.ldap.add(userBase, { objectClass: ['organizationalUnit', 'top'], ou: 'users', }); } catch (err) { // Ignore if already exists } try { await dm.ldap.add(teamsBase, { objectClass: ['organizationalUnit', 'top'], ou: 'teams', }); } catch (err) { // Ignore if already exists } }); it('should allow mailing lists anywhere when branch restrictions are disabled', async function () { this.timeout(10000); const testGroupDN = `cn=unrestricted-list-${timestamp},${teamsBase}`; const testUserDN = `uid=unrestuser-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'Unrestricted User', sn: 'User', uid: `unrestuser-${timestamp}`, mail: `unrestuser-${timestamp}@test.org`, }); // Create mailing list in teams branch (should succeed - no restrictions) await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `unrestricted-list-${timestamp}`, mail: `unrestricted-list-${timestamp}@test.org`, twakeMailboxType: `cn=mailingList,${mailboxTypeBase}`, member: testUserDN, twakeDepartmentLink: teamsBase, twakeDepartmentPath: 'Teams', }); await new Promise(resolve => setTimeout(resolve, 50)); // Mailing list should be created successfully expect(true).to.be.true; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore } try { await dm.ldap.delete(testUserDN); } catch (err) { // Ignore } } }); it('should allow team mailboxes anywhere when branch restrictions are disabled', async function () { this.timeout(10000); const testGroupDN = `cn=unrestricted-team-${timestamp},${teamsBase}`; const testUserDN = `uid=unrestteam-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'Unrestricted Team User', sn: 'User', uid: `unrestteam-${timestamp}`, mail: `unrestteam-${timestamp}@test.org`, }); // Create team mailbox in teams branch (should succeed - no restrictions) await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `unrestricted-team-${timestamp}`, mail: `unrestricted-team-${timestamp}@test.org`, twakeMailboxType: `cn=teamMailbox,${mailboxTypeBase}`, member: testUserDN, twakeDepartmentLink: teamsBase, twakeDepartmentPath: 'Teams', }); await new Promise(resolve => setTimeout(resolve, 50)); // Team mailbox should be created successfully expect(true).to.be.true; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore } try { await dm.ldap.delete(testUserDN); } catch (err) { // Ignore } } }); }); linagora-ldap-rest-16e557e/test/plugins/twake/jamesMailboxTypeTransitions.test.ts000066400000000000000000000344011522642357000303660ustar00rootroot00000000000000import nock from 'nock'; import { DM } from '../../../src/bin'; import James from '../../../src/plugins/twake/james'; import { expect } from 'chai'; import OnLdapChange from '../../../src/plugins/ldap/onChange'; import { skipIfMissingEnvVars, LDAP_ENV_VARS } from '../../helpers/env'; import LdapGroups from '../../../src/plugins/ldap/groups'; describe('James Mailbox Type Transitions', () => { const timestamp = Date.now(); let userBase: string; let groupBase: string; let nomenclatureBase: string; let mailboxTypeBase: string; let dm: DM; let james: James; let ldapGroups: LdapGroups; let scope: nock.Scope; before(async function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS]); // Initialize DNs after env vars are set userBase = `ou=users,${process.env.DM_LDAP_BASE}`; groupBase = process.env.DM_LDAP_GROUP_BASE || `ou=groups,${process.env.DM_LDAP_BASE}`; nomenclatureBase = `ou=nomenclature,${process.env.DM_LDAP_BASE}`; mailboxTypeBase = `ou=twakeMailboxType,${nomenclatureBase}`; // Create DM instance once for all tests dm = new DM(); dm.config.delegation_attribute = 'twakeDelegatedUsers'; dm.config.james_init_delay = 0; // No delay in tests await dm.ready; james = new James(dm); ldapGroups = new LdapGroups(dm); await dm.registerPlugin('onLdapChange', new OnLdapChange(dm)); await dm.registerPlugin('ldapGroups', ldapGroups); await dm.registerPlugin('james', james); // Mock James API calls for all mailbox operations scope = nock(process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000') .persist() // Mock identity sync .get(/\/users\/.*@test\.org\/identities$/) .reply(200, uri => { const email = uri.replace('/users/', '').replace('/identities', ''); return [ { id: `${email}-identity-id`, name: 'Test User', email: email, }, ]; }) .put(/\/users\/.*@test\.org\/identities\/.*-identity-id$/) .reply(200, { success: true }) // Mailing list operations .put(/\/address\/groups\/.*@test\.org\/.*@test\.org$/) .reply(204) .delete(/\/address\/groups\/.*@test\.org\/.*@test\.org$/) .reply(204) .delete(/\/address\/groups\/.*@test\.org$/) .reply(204) // Team mailbox operations .put(/\/domains\/test\.org\/team-mailboxes\/.*@test\.org$/) .reply(204) .put( /\/domains\/test\.org\/team-mailboxes\/.*@test\.org\/members\/.*@test\.org$/ ) .reply(204) .delete( /\/domains\/test\.org\/team-mailboxes\/.*@test\.org\/members\/.*@test\.org$/ ) .reply(204) .delete(/\/domains\/test\.org\/team-mailboxes\/.*@test\.org$/) .reply(204); }); after(function () { if (scope) { scope.persist(false); } nock.cleanAll(); }); beforeEach(async function () { this.timeout(10000); // Ensure required OUs exist try { await dm.ldap.add(userBase, { objectClass: ['organizationalUnit', 'top'], ou: 'users', }); } catch (err) { // Ignore if already exists } }); it('should transition from mailingList to teamMailbox', async function () { this.timeout(10000); const testGroupDN = `cn=transition1-${timestamp},${groupBase}`; const testUserDN = `uid=transuser1-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'Trans User 1', sn: 'User1', uid: `transuser1-${timestamp}`, mail: `transuser1-${timestamp}@test.org`, }); // Create mailing list await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `transition1-${timestamp}`, mail: `transition1-${timestamp}@test.org`, twakeMailboxType: `cn=mailingList,${mailboxTypeBase}`, member: testUserDN, twakeDepartmentLink: groupBase, twakeDepartmentPath: 'Test', }); await new Promise(resolve => setTimeout(resolve, 50)); // Transition to team mailbox await dm.ldap.modify(testGroupDN, { replace: { twakeMailboxType: `cn=teamMailbox,${mailboxTypeBase}`, }, }); await new Promise(resolve => setTimeout(resolve, 50)); // Verify transition occurred (checked by nock) expect(scope.isDone()).to.be.false; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore } try { await dm.ldap.delete(testUserDN); } catch (err) { // Ignore } } }); it('should transition from teamMailbox to mailingList (preserve mailbox)', async function () { this.timeout(10000); const testGroupDN = `cn=transition2-${timestamp},${groupBase}`; const testUserDN = `uid=transuser2-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'Trans User 2', sn: 'User2', uid: `transuser2-${timestamp}`, mail: `transuser2-${timestamp}@test.org`, }); // Create team mailbox await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `transition2-${timestamp}`, mail: `transition2-${timestamp}@test.org`, twakeMailboxType: `cn=teamMailbox,${mailboxTypeBase}`, member: testUserDN, twakeDepartmentLink: groupBase, twakeDepartmentPath: 'Test', }); await new Promise(resolve => setTimeout(resolve, 50)); // Transition to mailing list await dm.ldap.modify(testGroupDN, { replace: { twakeMailboxType: `cn=mailingList,${mailboxTypeBase}`, }, }); await new Promise(resolve => setTimeout(resolve, 50)); // Verify transition occurred // Team mailbox members should be removed (not deleted) // Mailing list should be created expect(scope.isDone()).to.be.false; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore } try { await dm.ldap.delete(testUserDN); } catch (err) { // Ignore } } }); it('should transition from group to teamMailbox (add mail attribute)', async function () { this.timeout(10000); const testGroupDN = `cn=transition3-${timestamp},${groupBase}`; const testUserDN = `uid=transuser3-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'Trans User 3', sn: 'User3', uid: `transuser3-${timestamp}`, mail: `transuser3-${timestamp}@test.org`, }); // Create simple group without mail await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `transition3-${timestamp}`, twakeMailboxType: `cn=group,${mailboxTypeBase}`, member: testUserDN, twakeDepartmentLink: groupBase, twakeDepartmentPath: 'Test', }); await new Promise(resolve => setTimeout(resolve, 50)); // Add mail and change to team mailbox await dm.ldap.modify(testGroupDN, { add: { mail: `transition3-${timestamp}@test.org`, }, replace: { twakeMailboxType: `cn=teamMailbox,${mailboxTypeBase}`, }, }); await new Promise(resolve => setTimeout(resolve, 50)); // Verify team mailbox was created expect(scope.isDone()).to.be.false; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore } try { await dm.ldap.delete(testUserDN); } catch (err) { // Ignore } } }); it('should transition from teamMailbox to group (remove mail attribute)', async function () { this.timeout(10000); const testGroupDN = `cn=transition4-${timestamp},${groupBase}`; const testUserDN = `uid=transuser4-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'Trans User 4', sn: 'User4', uid: `transuser4-${timestamp}`, mail: `transuser4-${timestamp}@test.org`, }); // Create team mailbox await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `transition4-${timestamp}`, mail: `transition4-${timestamp}@test.org`, twakeMailboxType: `cn=teamMailbox,${mailboxTypeBase}`, member: testUserDN, twakeDepartmentLink: groupBase, twakeDepartmentPath: 'Test', }); await new Promise(resolve => setTimeout(resolve, 50)); // Change to simple group await dm.ldap.modify(testGroupDN, { replace: { twakeMailboxType: `cn=group,${mailboxTypeBase}`, }, }); await new Promise(resolve => setTimeout(resolve, 50)); // Verify all members were removed from team mailbox (preserved) expect(scope.isDone()).to.be.false; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore } try { await dm.ldap.delete(testUserDN); } catch (err) { // Ignore } } }); it('should transition from group to mailingList (add mail attribute)', async function () { this.timeout(10000); const testGroupDN = `cn=transition5-${timestamp},${groupBase}`; const testUserDN = `uid=transuser5-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'Trans User 5', sn: 'User5', uid: `transuser5-${timestamp}`, mail: `transuser5-${timestamp}@test.org`, }); // Create simple group without mail await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `transition5-${timestamp}`, twakeMailboxType: `cn=group,${mailboxTypeBase}`, member: testUserDN, twakeDepartmentLink: groupBase, twakeDepartmentPath: 'Test', }); await new Promise(resolve => setTimeout(resolve, 50)); // Add mail and change to mailing list await dm.ldap.modify(testGroupDN, { add: { mail: `transition5-${timestamp}@test.org`, }, replace: { twakeMailboxType: `cn=mailingList,${mailboxTypeBase}`, }, }); await new Promise(resolve => setTimeout(resolve, 50)); // Verify mailing list was created expect(scope.isDone()).to.be.false; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore } try { await dm.ldap.delete(testUserDN); } catch (err) { // Ignore } } }); it('should transition from mailingList to group (disable mail functionality)', async function () { this.timeout(10000); const testGroupDN = `cn=transition6-${timestamp},${groupBase}`; const testUserDN = `uid=transuser6-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'Trans User 6', sn: 'User6', uid: `transuser6-${timestamp}`, mail: `transuser6-${timestamp}@test.org`, }); // Create mailing list await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `transition6-${timestamp}`, mail: `transition6-${timestamp}@test.org`, twakeMailboxType: `cn=mailingList,${mailboxTypeBase}`, member: testUserDN, twakeDepartmentLink: groupBase, twakeDepartmentPath: 'Test', }); await new Promise(resolve => setTimeout(resolve, 50)); // Change to simple group await dm.ldap.modify(testGroupDN, { replace: { twakeMailboxType: `cn=group,${mailboxTypeBase}`, }, }); await new Promise(resolve => setTimeout(resolve, 50)); // Verify mailing list was deleted expect(scope.isDone()).to.be.false; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore } try { await dm.ldap.delete(testUserDN); } catch (err) { // Ignore } } }); it('should remove all members when deleting a teamMailbox group', async function () { this.timeout(10000); const testGroupDN = `cn=transition7-${timestamp},${groupBase}`; const testUser1DN = `uid=transuser7-${timestamp},${userBase}`; const testUser2DN = `uid=transuser8-${timestamp},${userBase}`; try { // Create test users await dm.ldap.add(testUser1DN, { objectClass: ['top', 'inetOrgPerson'], cn: 'Trans User 7', sn: 'User7', uid: `transuser7-${timestamp}`, mail: `transuser7-${timestamp}@test.org`, }); await dm.ldap.add(testUser2DN, { objectClass: ['top', 'inetOrgPerson'], cn: 'Trans User 8', sn: 'User8', uid: `transuser8-${timestamp}`, mail: `transuser8-${timestamp}@test.org`, }); // Create team mailbox with 2 members await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `transition7-${timestamp}`, mail: `transition7-${timestamp}@test.org`, twakeMailboxType: `cn=teamMailbox,${mailboxTypeBase}`, member: [testUser1DN, testUser2DN], twakeDepartmentLink: groupBase, twakeDepartmentPath: 'Test', }); await new Promise(resolve => setTimeout(resolve, 50)); // Delete the group await dm.ldap.delete(testGroupDN); await new Promise(resolve => setTimeout(resolve, 50)); // Verify all members were removed (but mailbox preserved) // The team mailbox should NOT be deleted expect(scope.isDone()).to.be.false; } finally { // Cleanup users try { await dm.ldap.delete([testUser1DN, testUser2DN]); } catch (err) { // Ignore } } }); }); linagora-ldap-rest-16e557e/test/plugins/twake/jamesMailingLists.test.ts000066400000000000000000000230601522642357000262710ustar00rootroot00000000000000import nock from 'nock'; import { DM } from '../../../src/bin'; import James from '../../../src/plugins/twake/james'; import { expect } from 'chai'; import OnLdapChange from '../../../src/plugins/ldap/onChange'; import { skipIfMissingEnvVars, LDAP_ENV_VARS } from '../../helpers/env'; import LdapGroups from '../../../src/plugins/ldap/groups'; describe('James Mailing Lists', () => { const timestamp = Date.now(); let userBase: string; let groupBase: string; let dm: DM; let james: James; let ldapGroups: LdapGroups; let scope: nock.Scope; before(async function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS]); // Initialize DNs after env vars are set userBase = `ou=users,${process.env.DM_LDAP_BASE}`; groupBase = process.env.DM_LDAP_GROUP_BASE || `ou=groups,${process.env.DM_LDAP_BASE}`; // Create DM instance once for all tests dm = new DM(); dm.config.delegation_attribute = 'twakeDelegatedUsers'; dm.config.james_init_delay = 0; // No delay in tests await dm.ready; james = new James(dm); ldapGroups = new LdapGroups(dm); await dm.registerPlugin('onLdapChange', new OnLdapChange(dm)); await dm.registerPlugin('ldapGroups', ldapGroups); await dm.registerPlugin('james', james); // Mock James API calls for mailing lists scope = nock(process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000') .persist() // Mock identity sync for all emails (uses regex to match dynamic timestamps) .get(/\/users\/.*@test\.org\/identities$/) .reply(200, uri => { const email = uri.replace('/users/', '').replace('/identities', ''); return [ { id: `${email}-identity-id`, name: 'Test User', email: email, }, ]; }) .put(/\/users\/.*@test\.org\/identities\/.*-identity-id$/) .reply(200, { success: true }) // Create group members .put(/\/address\/groups\/list.*@test\.org\/member.*@test\.org$/) .reply(204) // Add new member .put(/\/address\/groups\/list.*@test\.org\/newmember.*@test\.org$/) .reply(204) // Delete member .delete(/\/address\/groups\/list.*@test\.org\/member.*@test\.org$/) .reply(204) // Delete entire group .delete(/\/address\/groups\/list.*@test\.org$/) .reply(204); }); after(function () { if (scope) { scope.persist(false); } nock.cleanAll(); }); beforeEach(async function () { // Increase timeout for setup this.timeout(10000); // Ensure ou=users exists try { await dm.ldap.add(userBase, { objectClass: ['organizationalUnit', 'top'], ou: 'users', }); } catch (err) { // Ignore if already exists } }); it('should create mailing list in James when group with mail is added', async function () { this.timeout(10000); const testGroupDN = `cn=mailinglist1-${timestamp},${groupBase}`; const testUser1DN = `uid=mluser1-${timestamp},${userBase}`; const testUser2DN = `uid=mluser2-${timestamp},${userBase}`; try { // Create test users await dm.ldap.add(testUser1DN, { objectClass: ['top', 'inetOrgPerson'], cn: 'ML User 1', sn: 'User1', uid: `mluser1-${timestamp}`, mail: `member1-${timestamp}@test.org`, }); await dm.ldap.add(testUser2DN, { objectClass: ['top', 'inetOrgPerson'], cn: 'ML User 2', sn: 'User2', uid: `mluser2-${timestamp}`, mail: `member2-${timestamp}@test.org`, }); const res = await ldapGroups.addGroup( `mailinglist1-${timestamp}`, [testUser1DN, testUser2DN], { mail: `list1-${timestamp}@test.org`, twakeDepartmentLink: `ou=organization,${process.env.DM_LDAP_BASE}`, twakeDepartmentPath: 'Test', } ); expect(res).to.be.true; } finally { // Cleanup for (const dn of [testGroupDN, testUser1DN, testUser2DN]) { try { await dm.ldap.delete(dn); } catch (err) { // Ignore } } } }); it('should add member to James group when member is added to LDAP group', async function () { this.timeout(10000); const testGroupDN = `cn=mailinglist2-${timestamp},${groupBase}`; const testUser1DN = `uid=mluser3-${timestamp},${userBase}`; const newUserDN = `uid=mluser4-${timestamp},${userBase}`; try { // Create initial user await dm.ldap.add(testUser1DN, { objectClass: ['top', 'inetOrgPerson'], cn: 'ML User 3', sn: 'User3', uid: `mluser3-${timestamp}`, mail: `member1-${timestamp}@test.org`, }); // Create the group await ldapGroups.addGroup(`mailinglist2-${timestamp}`, [testUser1DN], { mail: `list2-${timestamp}@test.org`, twakeDepartmentLink: `ou=organization,${process.env.DM_LDAP_BASE}`, twakeDepartmentPath: 'Test', }); // Create a new user to add await dm.ldap.add(newUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'ML User 4', sn: 'User4', uid: `mluser4-${timestamp}`, mail: `newmember-${timestamp}@test.org`, }); // Add member to group const res = await ldapGroups.addMember(testGroupDN, newUserDN); expect(res).to.be.true; } finally { // Cleanup for (const dn of [testGroupDN, newUserDN, testUser1DN]) { try { await dm.ldap.delete(dn); } catch (err) { // Ignore } } } }); it('should remove member from James group when member is deleted from LDAP group', async function () { this.timeout(10000); const testGroupDN = `cn=mailinglist3-${timestamp},${groupBase}`; const testUser1DN = `uid=mluser5-${timestamp},${userBase}`; const testUser2DN = `uid=mluser6-${timestamp},${userBase}`; try { // Create test users await dm.ldap.add(testUser1DN, { objectClass: ['top', 'inetOrgPerson'], cn: 'ML User 5', sn: 'User5', uid: `mluser5-${timestamp}`, mail: `member1-${timestamp}@test.org`, }); await dm.ldap.add(testUser2DN, { objectClass: ['top', 'inetOrgPerson'], cn: 'ML User 6', sn: 'User6', uid: `mluser6-${timestamp}`, mail: `member2-${timestamp}@test.org`, }); // Create the group with two members await ldapGroups.addGroup( `mailinglist3-${timestamp}`, [testUser1DN, testUser2DN], { mail: `list3-${timestamp}@test.org`, twakeDepartmentLink: `ou=organization,${process.env.DM_LDAP_BASE}`, twakeDepartmentPath: 'Test', } ); // Remove one member const res = await ldapGroups.deleteMember(testGroupDN, testUser1DN); expect(res).to.be.true; } finally { // Cleanup for (const dn of [testGroupDN, testUser1DN, testUser2DN]) { try { await dm.ldap.delete(dn); } catch (err) { // Ignore } } } }); it('should delete mailing list from James when group is deleted', async function () { this.timeout(10000); const testGroupDN = `cn=mailinglist4-${timestamp},${groupBase}`; const testUser1DN = `uid=mluser7-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUser1DN, { objectClass: ['top', 'inetOrgPerson'], cn: 'ML User 7', sn: 'User7', uid: `mluser7-${timestamp}`, mail: `member1-${timestamp}@test.org`, }); // Create the group await ldapGroups.addGroup(`mailinglist4-${timestamp}`, [testUser1DN], { mail: `list4-${timestamp}@test.org`, twakeDepartmentLink: `ou=organization,${process.env.DM_LDAP_BASE}`, twakeDepartmentPath: 'Test', }); // Delete the group const res = await ldapGroups.deleteGroup(testGroupDN); expect(res).to.be.true; } finally { // Cleanup for (const dn of [testGroupDN, testUser1DN]) { try { await dm.ldap.delete(dn); } catch (err) { // Ignore } } } }); it('should skip groups without mail attribute', async function () { this.timeout(10000); const testGroupDN = `cn=groupnomail5-${timestamp},${groupBase}`; const testUser1DN = `uid=mluser8-${timestamp},${userBase}`; try { // Track if James API was called (it shouldn't be) let jamesApiCalled = false; const tempScope = nock( process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000' ) .put(/\/address\/groups\/.*/) .reply(function () { jamesApiCalled = true; return [200, {}]; }); // Create test user await dm.ldap.add(testUser1DN, { objectClass: ['top', 'inetOrgPerson'], cn: 'ML User 8', sn: 'User8', uid: `mluser8-${timestamp}`, mail: `member1-${timestamp}@test.org`, }); // Create group without mail attribute const res = await ldapGroups.addGroup( `groupnomail5-${timestamp}`, [testUser1DN], { twakeDepartmentLink: `ou=organization,${process.env.DM_LDAP_BASE}`, twakeDepartmentPath: 'Test', } ); expect(res).to.be.true; // Verify James API was NOT called expect(jamesApiCalled).to.be.false; // Clean up temp nock tempScope.persist(false); } finally { // Cleanup for (const dn of [testGroupDN, testUser1DN]) { try { await dm.ldap.delete(dn); } catch (err) { // Ignore } } } }); }); linagora-ldap-rest-16e557e/test/plugins/twake/jamesTeamMailboxes.test.ts000066400000000000000000000243471522642357000264350ustar00rootroot00000000000000import nock from 'nock'; import { DM } from '../../../src/bin'; import James from '../../../src/plugins/twake/james'; import { expect } from 'chai'; import OnLdapChange from '../../../src/plugins/ldap/onChange'; import { skipIfMissingEnvVars, LDAP_ENV_VARS } from '../../helpers/env'; import LdapGroups from '../../../src/plugins/ldap/groups'; describe('James Team Mailboxes', () => { const timestamp = Date.now(); let userBase: string; let groupBase: string; let nomenclatureBase: string; let mailboxTypeBase: string; let dm: DM; let james: James; let ldapGroups: LdapGroups; let scope: nock.Scope; before(async function () { skipIfMissingEnvVars(this, [...LDAP_ENV_VARS]); // Initialize DNs after env vars are set userBase = `ou=users,${process.env.DM_LDAP_BASE}`; groupBase = process.env.DM_LDAP_GROUP_BASE || `ou=groups,${process.env.DM_LDAP_BASE}`; nomenclatureBase = `ou=nomenclature,${process.env.DM_LDAP_BASE}`; mailboxTypeBase = `ou=twakeMailboxType,${nomenclatureBase}`; // Create DM instance once for all tests dm = new DM(); dm.config.delegation_attribute = 'twakeDelegatedUsers'; dm.config.james_init_delay = 0; // No delay in tests await dm.ready; james = new James(dm); ldapGroups = new LdapGroups(dm); await dm.registerPlugin('onLdapChange', new OnLdapChange(dm)); await dm.registerPlugin('ldapGroups', ldapGroups); await dm.registerPlugin('james', james); // Mock James API calls for team mailboxes scope = nock(process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000') .persist() // Mock identity sync for all emails .get(/\/users\/.*@test\.org\/identities$/) .reply(200, uri => { const email = uri.replace('/users/', '').replace('/identities', ''); return [ { id: `${email}-identity-id`, name: 'Test User', email: email, }, ]; }) .put(/\/users\/.*@test\.org\/identities\/.*-identity-id$/) .reply(200, { success: true }) // Create team mailbox .put(/\/domains\/test\.org\/team-mailboxes\/team.*@test\.org$/) .reply(204) // Add team mailbox members .put( /\/domains\/test\.org\/team-mailboxes\/team.*@test\.org\/members\/.*@test\.org$/ ) .reply(204) // Delete team mailbox member .delete( /\/domains\/test\.org\/team-mailboxes\/team.*@test\.org\/members\/.*@test\.org$/ ) .reply(204) // Delete entire team mailbox .delete(/\/domains\/test\.org\/team-mailboxes\/team.*@test\.org$/) .reply(204); }); after(function () { if (scope) { scope.persist(false); } nock.cleanAll(); }); beforeEach(async function () { // Increase timeout for setup this.timeout(10000); // Ensure required OUs exist try { await dm.ldap.add(userBase, { objectClass: ['organizationalUnit', 'top'], ou: 'users', }); } catch (err) { // Ignore if already exists } try { await dm.ldap.add(nomenclatureBase, { objectClass: ['organizationalUnit', 'top'], ou: 'nomenclature', }); } catch (err) { // Ignore if already exists } try { await dm.ldap.add(mailboxTypeBase, { objectClass: ['organizationalUnit', 'top'], ou: 'twakeMailboxType', }); } catch (err) { // Ignore if already exists } // Create nomenclature entries for mailbox types const mailboxTypes = ['group', 'mailingList', 'teamMailbox']; for (const type of mailboxTypes) { try { await dm.ldap.add(`cn=${type},${mailboxTypeBase}`, { objectClass: ['top', 'applicationProcess'], cn: type, }); } catch (err) { // Ignore if already exists } } }); it('should create team mailbox in James when group with twakeMailboxType=teamMailbox is added', async function () { this.timeout(10000); const testGroupDN = `cn=team1-${timestamp},${groupBase}`; const testUser1DN = `uid=tmuser1-${timestamp},${userBase}`; const testUser2DN = `uid=tmuser2-${timestamp},${userBase}`; try { // Create test users await dm.ldap.add(testUser1DN, { objectClass: ['top', 'inetOrgPerson'], cn: 'TM User 1', sn: 'User1', uid: `tmuser1-${timestamp}`, mail: `tmmember1-${timestamp}@test.org`, }); await dm.ldap.add(testUser2DN, { objectClass: ['top', 'inetOrgPerson'], cn: 'TM User 2', sn: 'User2', uid: `tmuser2-${timestamp}`, mail: `tmmember2-${timestamp}@test.org`, }); // Create team mailbox group await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `team1-${timestamp}`, mail: `team1-${timestamp}@test.org`, twakeMailboxType: `cn=teamMailbox,${mailboxTypeBase}`, member: [testUser1DN, testUser2DN], twakeDepartmentLink: groupBase, twakeDepartmentPath: 'Test', }); // Hooks are awaited by ldap.add, no need for artificial timeout // Verify team mailbox was created (checked by nock) expect(scope.isDone()).to.be.false; // nock doesn't mark as done for persistent } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore cleanup errors } try { await dm.ldap.delete([testUser1DN, testUser2DN]); } catch (err) { // Ignore cleanup errors } } }); it('should add member to team mailbox when member is added to group', async function () { this.timeout(10000); const testGroupDN = `cn=team2-${timestamp},${groupBase}`; const testUser1DN = `uid=tmuser3-${timestamp},${userBase}`; const testUser2DN = `uid=tmuser4-${timestamp},${userBase}`; try { // Create test users await dm.ldap.add(testUser1DN, { objectClass: ['top', 'inetOrgPerson'], cn: 'TM User 3', sn: 'User3', uid: `tmuser3-${timestamp}`, mail: `tmmember3-${timestamp}@test.org`, }); await dm.ldap.add(testUser2DN, { objectClass: ['top', 'inetOrgPerson'], cn: 'TM User 4', sn: 'User4', uid: `tmuser4-${timestamp}`, mail: `tmmember4-${timestamp}@test.org`, }); // Create team mailbox with one member await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `team2-${timestamp}`, mail: `team2-${timestamp}@test.org`, twakeMailboxType: `cn=teamMailbox,${mailboxTypeBase}`, member: testUser1DN, twakeDepartmentLink: groupBase, twakeDepartmentPath: 'Test', }); // Add second member (hooks are awaited automatically) await dm.ldap.modify(testGroupDN, { add: { member: testUser2DN }, }); // Verify member was added (checked by nock) expect(scope.isDone()).to.be.false; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore cleanup errors } try { await dm.ldap.delete([testUser1DN, testUser2DN]); } catch (err) { // Ignore cleanup errors } } }); it('should remove member from team mailbox when member is deleted from group', async function () { this.timeout(10000); const testGroupDN = `cn=team3-${timestamp},${groupBase}`; const testUser1DN = `uid=tmuser5-${timestamp},${userBase}`; const testUser2DN = `uid=tmuser6-${timestamp},${userBase}`; try { // Create test users await dm.ldap.add(testUser1DN, { objectClass: ['top', 'inetOrgPerson'], cn: 'TM User 5', sn: 'User5', uid: `tmuser5-${timestamp}`, mail: `tmmember5-${timestamp}@test.org`, }); await dm.ldap.add(testUser2DN, { objectClass: ['top', 'inetOrgPerson'], cn: 'TM User 6', sn: 'User6', uid: `tmuser6-${timestamp}`, mail: `tmmember6-${timestamp}@test.org`, }); // Create team mailbox with two members await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `team3-${timestamp}`, mail: `team3-${timestamp}@test.org`, twakeMailboxType: `cn=teamMailbox,${mailboxTypeBase}`, member: [testUser1DN, testUser2DN], twakeDepartmentLink: groupBase, twakeDepartmentPath: 'Test', }); // Remove first member (hooks are awaited automatically) await dm.ldap.modify(testGroupDN, { delete: { member: testUser1DN }, }); // Verify member was removed from team mailbox (checked by nock) expect(scope.isDone()).to.be.false; } finally { // Cleanup try { await dm.ldap.delete(testGroupDN); } catch (err) { // Ignore cleanup errors } try { await dm.ldap.delete([testUser1DN, testUser2DN]); } catch (err) { // Ignore cleanup errors } } }); it('should delete team mailbox from James when group is deleted', async function () { this.timeout(10000); const testGroupDN = `cn=team4-${timestamp},${groupBase}`; const testUserDN = `uid=tmuser7-${timestamp},${userBase}`; try { // Create test user await dm.ldap.add(testUserDN, { objectClass: ['top', 'inetOrgPerson'], cn: 'TM User 7', sn: 'User7', uid: `tmuser7-${timestamp}`, mail: `tmmember7-${timestamp}@test.org`, }); // Create team mailbox await dm.ldap.add(testGroupDN, { objectClass: ['top', 'groupOfNames', 'twakeStaticGroup'], cn: `team4-${timestamp}`, mail: `team4-${timestamp}@test.org`, twakeMailboxType: `cn=teamMailbox,${mailboxTypeBase}`, member: testUserDN, twakeDepartmentLink: groupBase, twakeDepartmentPath: 'Test', }); // Delete the team mailbox group (hooks are awaited automatically) await dm.ldap.delete(testGroupDN); // Verify team mailbox was deleted (checked by nock) expect(scope.isDone()).to.be.false; } finally { // Cleanup user try { await dm.ldap.delete(testUserDN); } catch (err) { // Ignore cleanup errors } } }); }); linagora-ldap-rest-16e557e/test/schemas/000077500000000000000000000000001522642357000201525ustar00rootroot00000000000000linagora-ldap-rest-16e557e/test/schemas/twakeUsersSchema.test.ts000066400000000000000000000026711522642357000247640ustar00rootroot00000000000000import { expect } from 'chai'; import * as fs from 'fs'; import * as path from 'path'; describe('Twake Users Schema', () => { let schema: any; before(() => { const schemaPath = path.join( __dirname, '../../static/schemas/twake/users.json' ); const schemaContent = fs.readFileSync(schemaPath, 'utf-8'); schema = JSON.parse(schemaContent); }); it('should have organizationLink role on twakeDepartmentLink', () => { expect(schema.attributes).to.have.property('twakeDepartmentLink'); expect(schema.attributes.twakeDepartmentLink).to.have.property( 'role', 'organizationLink' ); }); it('should have organizationPath role on twakeDepartmentPath', () => { expect(schema.attributes).to.have.property('twakeDepartmentPath'); expect(schema.attributes.twakeDepartmentPath).to.have.property( 'role', 'organizationPath' ); }); it('should have required organization fields', () => { expect(schema.attributes.twakeDepartmentLink).to.have.property( 'required', true ); expect(schema.attributes.twakeDepartmentPath).to.have.property( 'required', true ); }); it('should have correct types for organization fields', () => { expect(schema.attributes.twakeDepartmentLink).to.have.property( 'type', 'string' ); expect(schema.attributes.twakeDepartmentPath).to.have.property( 'type', 'string' ); }); }); linagora-ldap-rest-16e557e/test/setup.ts000066400000000000000000000055131522642357000202430ustar00rootroot00000000000000/** * Global test setup using Mocha root hooks * This approach works better with tsx/cjs * * Strategy: * - If external LDAP env vars are set → use them * - If not → start embedded LDAP server in Docker */ import { getGlobalTestLdapServer, stopGlobalTestLdapServer, LdapTestServer, } from './helpers/ldapServer'; import { hasExternalLdap } from './helpers/env'; // Export the global server reference so other modules can access it export let globalServer: LdapTestServer | null = null; // Track whether we started an embedded server (to clean it up later) let usingEmbeddedLdap = false; export const mochaHooks = { async beforeAll(this: Mocha.Context) { this.timeout(120000); // 2 minutes for LDAP server startup console.log('\n🚀 Setting up global test environment...\n'); try { // Check if external LDAP is configured if (hasExternalLdap()) { console.log('✓ Using external LDAP server'); console.log(` URL: ${process.env.DM_LDAP_URL}`); console.log(` Base DN: ${process.env.DM_LDAP_BASE}`); console.log(''); usingEmbeddedLdap = false; } else { console.log( 'ℹ️ No external LDAP configured, starting embedded LDAP server...\n' ); // Start embedded LDAP server globalServer = await getGlobalTestLdapServer(); usingEmbeddedLdap = true; // Set environment variables for all tests const envVars = globalServer.getEnvVars(); Object.entries(envVars).forEach(([key, value]) => { process.env[key] = value; }); // Set top organization for tests requiring it process.env.DM_LDAP_TOP_ORGANIZATION = `ou=organization,${envVars.DM_LDAP_BASE}`; console.log('✓ Embedded LDAP server ready'); console.log(` URL: ${envVars.DM_LDAP_URL}`); console.log(` Base DN: ${envVars.DM_LDAP_BASE}`); console.log(` Top Org: ${process.env.DM_LDAP_TOP_ORGANIZATION}`); console.log(''); } // Set James mock URL (tests will use nock) process.env.DM_JAMES_WEBADMIN_URL = process.env.DM_JAMES_WEBADMIN_URL || 'http://localhost:8000'; process.env.DM_JAMES_WEBADMIN_TOKEN = process.env.DM_JAMES_WEBADMIN_TOKEN || 'test-token'; } catch (err) { console.error('❌ Failed to setup test environment:', err); throw err; } }, async afterAll() { console.log('\n🧹 Cleaning up global test environment...\n'); try { // Only stop the embedded LDAP server if we started it if (usingEmbeddedLdap) { await stopGlobalTestLdapServer(); console.log('✓ Embedded LDAP server stopped\n'); } else { console.log('✓ External LDAP server left running\n'); } } catch (err) { console.error('❌ Failed to cleanup test environment:', err); } }, }; linagora-ldap-rest-16e557e/test/z-plugin-load.test.ts000066400000000000000000000023311522642357000225360ustar00rootroot00000000000000import { expect } from 'chai'; import request from 'supertest'; import { DM } from '../src/bin/index'; describe('Plugin Loading', () => { let server: DM | null; before(async () => { process.env.NODE_ENV = 'test'; process.env.DM_PORT = '64322'; process.env.DM_PLUGINS = '../../dist/plugins/demo/helloworld.js,../../test/__plugins__/hello/index.js'; // @ts-ignore server = new DM(); await server.ready; await server.run(); }); it('should load the helloworld plugin and respond to /api/hello', async () => { let res = await request(`http://localhost:${process.env.DM_PORT}`).get( '/api/hello' ); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ message: 'Hello', hookResults: [] }); res = await request(`http://localhost:${process.env.DM_PORT}`).get( '/hellopath' ); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ message: 'Hello path' }); expect(server?.loadedPlugins.hello).to.be.an('object'); expect(server?.loadedPlugins.hellopath).to.be.an('object'); }); after(() => { delete process.env.NODE_ENV; delete process.env.DM_PORT; delete process.env.DM_PLUGINS; server?.stop(); }); }); linagora-ldap-rest-16e557e/tsconfig.browser.json000066400000000000000000000007421522642357000217440ustar00rootroot00000000000000{ "extends": "./tsconfig.json", "compilerOptions": { "target": "ES2020", "module": "ES2020", "lib": ["ES2020", "DOM"], "declaration": true, "declarationDir": "./static/browser/types", "outDir": "./static/browser", "moduleResolution": "node", "esModuleInterop": true, "strict": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/browser/**/*"], "exclude": ["node_modules", "dist", "test"] } linagora-ldap-rest-16e557e/tsconfig.json000066400000000000000000000007051522642357000202610ustar00rootroot00000000000000{ "compilerOptions": { "target": "ES2020", "module": "es2022", "moduleResolution": "node", "declaration": true, "strict": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "outDir": "./dist", "rootDir": "./" }, "include": ["src/**/*", "test/**/*"], "exclude": ["node_modules", "dist"] }