You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@directory.apache.org by bd...@apache.org on 2026/05/31 19:37:17 UTC
(directory-scimple) 01/01: Add LDAP-backed SCIM server reference implementation
This is an automated email from the ASF dual-hosted git repository.
bdemers pushed a commit to branch ldap-reference
in repository https://gitbox.apache.org/repos/asf/directory-scimple.git
commit f63e594865fb2ce7b3f951ccc1046e1400cc63f5
Author: Brian Demers <bd...@apache.org>
AuthorDate: Tue Apr 7 21:47:39 2026 -0400
Add LDAP-backed SCIM server reference implementation
Adds a new scim-server-ldap module providing a reference SCIM server
backed by an LDAP directory (Apache Directory Server / OpenLDAP).
Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
THREAT_MODEL.md | 2 +-
pom.xml | 6 +
reference-projects/scim-server-ldap/README.md | 191 +++++
reference-projects/scim-server-ldap/pom.xml | 220 ++++++
.../directory/scim/ldap/LdapApplication.java | 151 ++++
.../scim/ldap/ldap/EmbeddedLdapServer.java | 255 +++++++
.../scim/ldap/ldap/LdapConnectionManager.java | 144 ++++
.../apache/directory/scim/ldap/ldap/LdapDao.java | 424 +++++++++++
.../directory/scim/ldap/ldap/ScimLdapConfig.java | 228 ++++++
.../scim/ldap/mapping/AttributeMapper.java | 573 +++++++++++++++
.../scim/ldap/mapping/FilterTranslator.java | 191 +++++
.../scim/ldap/service/LdapGroupRepository.java | 394 ++++++++++
.../scim/ldap/service/LdapUserRepository.java | 297 ++++++++
.../scim-server-ldap/src/main/resources/beans.xml | 22 +
.../src/main/resources/logback.xml | 26 +
.../src/main/resources/scim-ldap.yml | 79 ++
.../apache/directory/scim/ldap/LdapTestServer.java | 208 ++++++
.../scim/ldap/ldap/EmbeddedLdapServerTest.java | 78 ++
.../directory/scim/ldap/ldap/LdapDaoTest.java | 315 ++++++++
.../scim/ldap/ldap/ScimLdapConfigTest.java | 200 ++++++
.../scim/ldap/mapping/AttributeMapperTest.java | 791 +++++++++++++++++++++
.../scim/ldap/mapping/FilterTranslatorTest.java | 444 ++++++++++++
.../scim/ldap/service/LdapGroupRepositoryTest.java | 724 +++++++++++++++++++
.../scim/ldap/service/LdapUserRepositoryTest.java | 497 +++++++++++++
...ce.junit.EmbeddedServerExtension$ScimTestServer | 19 +
.../src/test/resources/scim-ldap.yml | 64 ++
scim-coverage/pom.xml | 4 +
src/spotbugs/excludes.xml | 9 +
28 files changed, 6555 insertions(+), 1 deletion(-)
diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
index 6737302a..56e0158b 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -107,7 +107,7 @@ the listening socket, TLS termination, and the authentication boundary.
**Code that ships in the repo but is out of this model:**
- `scim-server-examples/**` and `reference-projects/scim-server-ldap` — **example/demo applications, not published to
- Maven Central** *(enforced: the `scim-server-examples` parent pom sets `maven.deploy.skip` / `skipNexusStagingDeployMojo`)*. They open sockets and store data (the
+ Maven Central** *(enforced: the `scim-server-examples` parent pom and the `scim-server-ldap` module each set `maven.deploy.skip` / `skipNexusStagingDeployMojo`)*. They open sockets and store data (the
`InMemoryUserService`
examples hold passwords in plaintext) and are **not** production-hardened. Findings here are
`OUT-OF-MODEL: unsupported-component` (§13). The example READMEs carry a demo-only warning.
diff --git a/pom.xml b/pom.xml
index 9e2e79ef..b66930a5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -68,6 +68,7 @@
<module>scim-core</module>
<module>scim-server</module>
<module>scim-server-examples</module>
+ <module>reference-projects/scim-server-ldap</module>
<module>support/spring-boot</module>
<module>scim-tools</module>
<module>scim-compliance-tests</module>
@@ -212,6 +213,11 @@
<artifactId>scim-spring-boot-starter</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.directory.scimple</groupId>
+ <artifactId>scim-server-ldap</artifactId>
+ <version>1.0.0-SNAPSHOT</version>
+ </dependency>
<dependency>
<groupId>org.jboss.weld.se</groupId>
diff --git a/reference-projects/scim-server-ldap/README.md b/reference-projects/scim-server-ldap/README.md
new file mode 100644
index 00000000..74de6b82
--- /dev/null
+++ b/reference-projects/scim-server-ldap/README.md
@@ -0,0 +1,191 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+# SCIMple LDAP Server
+
+A SCIM 2.0 server backed by an LDAP directory, built on [Apache Directory SCIMple](https://directory.apache.org/scimple/) and the [Apache Directory LDAP API](https://directory.apache.org/api/).
+
+This module implements `Repository<ScimUser>` and `Repository<ScimGroup>` against LDAP, translating SCIM operations into LDAP reads and writes. It is a reference implementation intended to be adapted for production deployments with real LDAP directories.
+
+## Building
+
+Build the full SCIMple project (the LDAP module depends on other SCIMple modules):
+
+```shell
+./mvnw package -DskipTests
+```
+
+This produces an executable uber JAR at `reference-projects/scim-server-ldap/target/scim-server-ldap-1.0.0-SNAPSHOT-exec.jar`.
+
+> **Note:** The uber JAR is built with the [Spring Boot Maven Plugin](https://docs.spring.io/spring-boot/maven-plugin/) for its
+> JAR repackaging capability only — the project has **no runtime dependency on Spring**. The JAR uses Spring Boot's `JarLauncher`
+> as the entry point, which delegates to `LdapApplication.main()`. If you inspect the manifest, that is why you will see Spring
+> Boot references.
+
+## Quick Start
+
+Run the server with an embedded [Apache DS](https://directory.apache.org/apacheds/) instance (no external LDAP required):
+
+```shell
+java -Dldap.embedded=true -jar reference-projects/scim-server-ldap/target/scim-server-ldap-1.0.0-SNAPSHOT-exec.jar
+```
+
+Or, during development, run directly via Maven without building the uber JAR first:
+
+```shell
+./mvnw exec:java -pl reference-projects/scim-server-ldap -Dldap.embedded=true
+```
+
+The embedded mode seeds the directory with sample users and a group. Test it:
+
+```shell
+curl http://localhost:8080/Users
+curl http://localhost:8080/Groups
+curl http://localhost:8080/ServiceProviderConfig
+```
+
+SCIM endpoints expect `Content-Type: application/scim+json` for write operations (POST, PUT, PATCH).
+
+The server port defaults to 8080 and can be changed with `-Dserver.port=9090`.
+
+To connect to an external LDAP server instead, configure `src/main/resources/scim-ldap.yml` and run without the embedded flag.
+
+## Configuration
+
+All configuration lives in a single file: **`scim-ldap.yml`** on the classpath.
+
+```yaml
+ldap:
+ embedded: false # set to true to start an in-memory ApacheDS (ignores host/port/bind settings)
+ host: localhost
+ port: 389
+ bindDn: cn=admin,dc=example,dc=com
+ bindPassword: secret
+ useTls: false
+ userBaseDn: ou=users,dc=example,dc=com
+ groupBaseDn: ou=groups,dc=example,dc=com
+
+user:
+ objectClasses: [inetOrgPerson, organizationalPerson, person, top]
+ rdnAttribute: uid
+ attributes:
+ userName: uid
+ "name.givenName": givenName
+ "name.familyName": sn
+ displayName: displayName
+ "emails.value": mail
+ # ... full mapping in scim-ldap.yml
+
+group:
+ objectClasses: [groupOfNames, top]
+ rdnAttribute: cn
+ attributes:
+ displayName: cn
+ "members.value": member
+```
+
+### System Property Overrides
+
+LDAP connection settings can be overridden at runtime via system properties, which is useful for containerized deployments where secrets come from the environment:
+
+```shell
+java -Dldap.host=ldap.corp.example.com \
+ -Dldap.port=636 \
+ -Dldap.bind.dn=cn=scim,ou=services,dc=corp \
+ -Dldap.bind.password=$LDAP_PASSWORD \
+ -Dldap.use.tls=true \
+ -jar reference-projects/scim-server-ldap/target/scim-server-ldap-1.0.0-SNAPSHOT-exec.jar
+```
+
+| System Property | YAML Key | Default |
+|-----------------------|---------------------|-------------------------------|
+| `ldap.embedded` | `ldap.embedded` | `false` |
+| `ldap.host` | `ldap.host` | `localhost` |
+| `ldap.port` | `ldap.port` | `389` |
+| `ldap.bind.dn` | `ldap.bindDn` | `cn=admin,dc=example,dc=com` |
+| `ldap.bind.password` | `ldap.bindPassword` | `secret` |
+| `ldap.use.tls` | `ldap.useTls` | `false` |
+| `ldap.base.dn.users` | `ldap.userBaseDn` | `ou=users,dc=example,dc=com` |
+| `ldap.base.dn.groups` | `ldap.groupBaseDn` | `ou=groups,dc=example,dc=com` |
+| `server.port` | - | `8080` |
+
+## Architecture
+
+```
+SCIM Request
+ |
+ v
++-------------------+ +------------------+ +---------------------+
+| LdapUserRepository|---->| AttributeMapper |---->| LdapDao |
+| LdapGroupRepository | (SCIM <-> LDAP) | | (CRUD operations) |
++-------------------+ +------------------+ +---------------------+
+ | | |
+ | +------------------+ +---------------------+
+ +--------------->| FilterTranslator | | LdapConnectionManager
+ | (SCIM -> LDAP) | | (connection pool) |
+ +------------------+ +---------------------+
+```
+
+### Repository Layer (`service/`)
+
+`LdapUserRepository` and `LdapGroupRepository` implement SCIMple's `Repository<T>` SPI. They handle:
+
+- **CRUD operations** — create, get, update, patch, delete
+- **Search with filtering** — delegates to `FilterTranslator` then executes against LDAP
+- **Identity via entryUUID** — SCIM resource IDs are the LDAP server's `entryUUID` operational attribute (immutable, server-assigned)
+- **Group membership resolution** — bidirectional mapping between member DNs and SCIM entryUUIDs
+
+### Filter Translation (`mapping/FilterTranslator`)
+
+SCIM filter expressions are translated into LDAP filter strings. All SCIM comparison operators are supported:
+
+| SCIM | LDAP | Example |
+|---|---|---|
+| `eq` | `(attr=value)` | `userName eq "jdoe"` -> `(uid=jdoe)` |
+| `co` | `(attr=*value*)` | `displayName co "test"` -> `(displayName=*test*)` |
+| `sw` | `(attr=value*)` | `userName sw "j"` -> `(uid=j*)` |
+| `ew` | `(attr=*value)` | `emails.value ew "@example.com"` -> `(mail=*@example.com)` |
+| `pr` | `(attr=*)` | `title pr` -> `(title=*)` |
+| `gt`, `ge`, `lt`, `le` | Ordering filters | Mapped to LDAP `>=`/`<=` combinations |
+| `and`, `or`, `not` | `(&...)`, `(\|...)`, `(!...)` | Logical composition |
+
+Attribute names are resolved through the configurable SCIM-to-LDAP mapping (e.g., `userName` -> `uid`). Filter values are escaped per RFC 4515.
+
+### Attribute Mapping (`mapping/AttributeMapper`)
+
+Converts between SCIM resources and LDAP entries using the `user` and `group` sections of `scim-ldap.yml`. The mapping is a simple key-value structure:
+
+```yaml
+# SCIM attribute path -> LDAP attribute name
+userName: uid
+"name.givenName": givenName
+"emails.value": mail
+```
+
+To adapt for a different LDAP schema (e.g., Active Directory), change the attribute mapping and objectClasses in `scim-ldap.yml` — no code changes required.
+
+## Testing
+
+The module uses the SCIMple compliance test suite (`scim-compliance-tests`) with an embedded [Apache DS](https://directory.apache.org/apacheds/) server. Run the tests with:
+
+```shell
+./mvnw verify -pl reference-projects/scim-server-ldap
+```
+
+The test configuration in `src/test/resources/scim-ldap.yml` adds `extensibleObject` to the user objectClasses so that custom attributes (`scimActive`, `scimPhoneTypes`) are accepted by the Apache DS schema.
diff --git a/reference-projects/scim-server-ldap/pom.xml b/reference-projects/scim-server-ldap/pom.xml
new file mode 100644
index 00000000..9272cf10
--- /dev/null
+++ b/reference-projects/scim-server-ldap/pom.xml
@@ -0,0 +1,220 @@
+<!-- Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License. -->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.directory.scimple</groupId>
+ <artifactId>scimple</artifactId>
+ <version>1.0.0-SNAPSHOT</version>
+ <relativePath>../..</relativePath>
+ </parent>
+
+ <artifactId>scim-server-ldap</artifactId>
+ <name>SCIMple - Reference - LDAP Server</name>
+
+ <properties>
+ <module.name>org.apache.directory.scim.ldap</module.name>
+ <version.ldap-api>2.1.7</version.ldap-api>
+ <version.apacheds>2.0.0.AM27</version.apacheds>
+ <!-- Reference/demo module — never published to Maven Central -->
+ <maven.deploy.skip>true</maven.deploy.skip>
+ <skipNexusStagingDeployMojo>true</skipNexusStagingDeployMojo>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.glassfish.jersey.containers</groupId>
+ <artifactId>jersey-container-grizzly2-http</artifactId>
+ <scope>runtime</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.glassfish.jersey.inject</groupId>
+ <artifactId>jersey-hk2</artifactId>
+ <scope>runtime</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.glassfish.jersey.ext.cdi</groupId>
+ <artifactId>jersey-weld2-se</artifactId>
+ <scope>runtime</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.jboss.weld.se</groupId>
+ <artifactId>weld-se-core</artifactId>
+ <scope>runtime</scope>
+ </dependency>
+ <dependency>
+ <groupId>jakarta.servlet</groupId>
+ <artifactId>jakarta.servlet-api</artifactId>
+ <scope>runtime</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>jakarta.ws.rs</groupId>
+ <artifactId>jakarta.ws.rs-api</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>jakarta.inject</groupId>
+ <artifactId>jakarta.inject-api</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>jakarta.annotation</groupId>
+ <artifactId>jakarta.annotation-api</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>jakarta.enterprise</groupId>
+ <artifactId>jakarta.enterprise.cdi-api</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>jakarta.enterprise</groupId>
+ <artifactId>jakarta.enterprise.lang-model</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.directory.scimple</groupId>
+ <artifactId>scim-server</artifactId>
+ </dependency>
+
+ <!-- Apache Directory LDAP API -->
+ <dependency>
+ <groupId>org.apache.directory.api</groupId>
+ <artifactId>api-all</artifactId>
+ <version>${version.ldap-api}</version>
+ </dependency>
+
+ <!-- Embedded ApacheDS (used when ldap.embedded=true) -->
+ <dependency>
+ <groupId>org.apache.directory.server</groupId>
+ <artifactId>apacheds-core-annotations</artifactId>
+ <version>${version.apacheds}</version>
+ <exclusions>
+ <exclusion>
+ <groupId>org.apache.directory.api</groupId>
+ <artifactId>api-ldap-schema-data</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.directory.server</groupId>
+ <artifactId>apacheds-protocol-ldap</artifactId>
+ <version>${version.apacheds}</version>
+ <exclusions>
+ <exclusion>
+ <groupId>org.apache.directory.api</groupId>
+ <artifactId>api-ldap-schema-data</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+
+ <!-- YAML configuration support -->
+ <dependency>
+ <groupId>com.fasterxml.jackson.dataformat</groupId>
+ <artifactId>jackson-dataformat-yaml</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>jul-to-slf4j</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-classic</artifactId>
+ </dependency>
+
+ <!-- Test dependencies -->
+ <dependency>
+ <groupId>org.apache.directory.scimple</groupId>
+ <artifactId>scim-compliance-tests</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.directory.server</groupId>
+ <artifactId>apacheds-server-integ</artifactId>
+ <version>${version.apacheds}</version>
+ <scope>test</scope>
+ <exclusions>
+ <!-- Exclude to avoid duplicate schema LDIF resources with api-all -->
+ <exclusion>
+ <groupId>org.apache.directory.api</groupId>
+ <artifactId>api-ldap-schema-data</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-core</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-junit-jupiter</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.assertj</groupId>
+ <artifactId>assertj-core</artifactId>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.codehaus.mojo</groupId>
+ <artifactId>exec-maven-plugin</artifactId>
+ <configuration>
+ <mainClass>org.apache.directory.scim.ldap.LdapApplication</mainClass>
+ </configuration>
+ </plugin>
+ <!-- Repackages the module JAR as an executable uber JAR.
+ This plugin is used solely for packaging — the project has no Spring dependency. -->
+ <plugin>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-maven-plugin</artifactId>
+ <version>${version.spring-boot4}</version>
+ <executions>
+ <execution>
+ <goals>
+ <goal>repackage</goal>
+ </goals>
+ <configuration>
+ <mainClass>org.apache.directory.scim.ldap.LdapApplication</mainClass>
+ <classifier>exec</classifier>
+ <!-- Weld SE cannot discover bean archives inside Spring Boot's nested JAR layout
+ (BOOT-INF/lib/). JARs listed here are extracted to a temp directory at runtime
+ so Weld's FileSystemBeanArchiveHandler can scan their beans.xml. -->
+ <requiresUnpack>
+ <dependency>
+ <groupId>org.glassfish.jersey.ext.cdi</groupId>
+ <artifactId>jersey-weld2-se</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.directory.scimple</groupId>
+ <artifactId>scim-core</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.directory.scimple</groupId>
+ <artifactId>scim-server</artifactId>
+ </dependency>
+ </requiresUnpack>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+
+</project>
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/LdapApplication.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/LdapApplication.java
new file mode 100644
index 00000000..b48dcb71
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/LdapApplication.java
@@ -0,0 +1,151 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Produces;
+import jakarta.enterprise.inject.se.SeContainer;
+import jakarta.enterprise.inject.se.SeContainerInitializer;
+import jakarta.ws.rs.SeBootstrap;
+import jakarta.ws.rs.core.UriBuilder;
+import org.apache.directory.scim.ldap.ldap.LdapConnectionManager;
+import org.apache.directory.scim.ldap.ldap.LdapDao;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.ldap.mapping.AttributeMapper;
+import org.apache.directory.scim.ldap.mapping.FilterTranslator;
+import org.apache.directory.scim.ldap.service.LdapGroupRepository;
+import org.apache.directory.scim.ldap.service.LdapUserRepository;
+import org.apache.directory.scim.server.configuration.ServerConfiguration;
+
+import static org.apache.directory.scim.spec.schema.ServiceProviderConfiguration.AuthenticationSchema.oauthBearer;
+
+import java.net.URI;
+import java.util.Set;
+
+import jakarta.ws.rs.core.Application;
+import org.apache.directory.scim.server.rest.ScimResourceHelper;
+import org.slf4j.LoggerFactory;
+import org.slf4j.bridge.SLF4JBridgeHandler;
+
+/**
+ * JAX-RS {@link Application} that bootstraps a standalone SCIM server backed by LDAP.
+ *
+ * <p>This application uses Jersey 3 as the JAX-RS runtime, Weld SE for CDI dependency injection,
+ * and {@link SeBootstrap} to start an embedded HTTP server. It registers all SCIMple JAX-RS
+ * resource and feature classes and produces a {@link ServerConfiguration} bean for the SCIM
+ * service provider.</p>
+ *
+ * @see ScimResourceHelper#scimpleFeatureAndResourceClasses()
+ * @see ServerConfiguration
+ */
+// @ApplicationPath("v2")
+// Embedded Jersey + Grizzly ignores the ApplicationPath annotation
+// https://github.com/eclipse-ee4j/jersey/issues/3222
+@ApplicationScoped
+public class LdapApplication extends Application {
+
+ private static final org.slf4j.Logger LOG = LoggerFactory.getLogger(LdapApplication.class);
+
+ /**
+ * Returns the set of JAX-RS resource and feature classes required by the SCIMple server.
+ *
+ * <p>Delegates to {@link ScimResourceHelper#scimpleFeatureAndResourceClasses()} to register
+ * all SCIM endpoint resources, exception mappers, and JAX-RS features.</p>
+ *
+ * @return a set of JAX-RS classes to register with the runtime
+ */
+ @Override
+ public Set<Class<?>> getClasses() {
+ return ScimResourceHelper.scimpleFeatureAndResourceClasses();
+ }
+
+ /**
+ * CDI producer method that provides the {@link ServerConfiguration} for the SCIM service.
+ *
+ * <p>Configures the server with an identifier and documentation URI pointing to the
+ * Apache Directory SCIMple project.</p>
+ *
+ * @return the SCIM server configuration bean
+ */
+ @Produces
+ ServerConfiguration serverConfiguration() {
+ // TODO: SCIMple should auto-detect filter support from the Repository implementation
+ // rather than requiring manual configuration in ServerConfiguration. This would improve
+ // the developer experience across all SCIMple-based servers.
+ return new ServerConfiguration()
+ .setId("scimple-ldap-example")
+ .setDocumentationUri("https://github.com/apache/directory-scimple")
+ .setSupportsFilter(true)
+ // This does not enforce authentication. Use oauthBearer() or httpBasic() as appropriate.
+ .addAuthenticationSchema(oauthBearer());
+ }
+
+ /**
+ * Starts the standalone SCIM server.
+ *
+ * <p>The server port defaults to 8080 and can be overridden via the {@code server.port}
+ * system property (e.g., {@code -Dserver.port=9090}).</p>
+ *
+ * <p>Initializes a Weld SE CDI container, starts a JAX-RS {@link SeBootstrap} instance,
+ * and blocks the main thread until interrupted (e.g., via {@code CTRL+C}). JUL logging
+ * is bridged to SLF4J before startup.</p>
+ *
+ * <p>Usage: {@code java -jar scim-server-ldap.jar}</p>
+ *
+ * @param args command-line arguments (currently unused)
+ */
+ public static void main(String[] args) {
+
+ // configure JUL logging
+ SLF4JBridgeHandler.install();
+
+ try {
+
+ // Register CDI beans explicitly instead of using addPackages() — Spring Boot's nested
+ // JAR classloader uses URLs that Weld SE's package scanner cannot enumerate.
+ SeContainer container = SeContainerInitializer.newInstance()
+ .addBeanClasses(
+ LdapApplication.class,
+ ScimLdapConfig.class,
+ LdapConnectionManager.class,
+ LdapDao.class,
+ AttributeMapper.class,
+ FilterTranslator.class,
+ LdapUserRepository.class,
+ LdapGroupRepository.class
+ )
+ .initialize();
+
+ int port = Integer.parseInt(System.getProperty("server.port", "8080"));
+ LdapApplication app = new LdapApplication();
+ SeBootstrap.start(app, SeBootstrap.Configuration.builder().port(port).build())
+ .thenAccept(instance -> instance.stopOnShutdown(stopResult -> container.close()));
+ URI uri = UriBuilder.fromUri("http://localhost/").port(port).build();
+
+ System.out.printf("Application started: %s%nStop the application using CTRL+C%n", uri.toString());
+
+ // block and wait shut down signal, like CTRL+C
+ Thread.currentThread().join();
+
+ } catch (InterruptedException ex) {
+ LOG.error("Service Interrupted", ex);
+ }
+ }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/EmbeddedLdapServer.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/EmbeddedLdapServer.java
new file mode 100644
index 00000000..5c69dbd5
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/EmbeddedLdapServer.java
@@ -0,0 +1,255 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.ldap;
+
+import org.apache.directory.api.ldap.model.entry.DefaultEntry;
+import org.apache.directory.api.ldap.model.name.Dn;
+import org.apache.directory.api.ldap.model.schema.SchemaManager;
+import org.apache.directory.server.core.api.DirectoryService;
+import org.apache.directory.server.core.factory.DefaultDirectoryServiceFactory;
+import org.apache.directory.server.core.partition.impl.avl.AvlPartition;
+import org.apache.directory.server.ldap.LdapServer;
+import org.apache.directory.server.protocol.shared.transport.TcpTransport;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.nio.channels.ServerSocketChannel;
+
+/**
+ * Starts an embedded Apache Directory Server for development and demonstration purposes.
+ *
+ * <p>This is a plain helper class (not a CDI bean) instantiated by {@link LdapConnectionManager}
+ * when {@link ScimLdapConfig#isEmbedded()} is {@code true}. It creates an in-memory LDAP
+ * directory with the base OUs needed for SCIM user and group operations, and seeds it with
+ * sample users and a group so the server returns useful data immediately.</p>
+ *
+ * <p>The embedded server always listens on a random available port on {@code 127.0.0.1}.
+ * After calling {@link #start(ScimLdapConfig)}, use {@link #getHost()} and
+ * {@link #getPort()} to obtain the connection details.</p>
+ *
+ * @see ScimLdapConfig#isEmbedded()
+ */
+public class EmbeddedLdapServer {
+
+ private static final Logger LOG = LoggerFactory.getLogger(EmbeddedLdapServer.class);
+ private DirectoryService directoryService;
+ private LdapServer ldapServer;
+ private String host;
+ private int port;
+
+ /**
+ * Starts the embedded Apache DS instance and seeds the base directory structure.
+ *
+ * <p>Creates an in-memory directory with a partition rooted at the base DN derived from
+ * {@link ScimLdapConfig#getUserBaseDn()}, then seeds it with organizational units, sample
+ * users, and a group. The server binds to {@code 127.0.0.1} on a randomly selected
+ * available port.</p>
+ *
+ * @param config the SCIM-LDAP configuration providing base DNs for users and groups
+ * @throws Exception if the directory service or LDAP server fails to start
+ */
+ public void start(ScimLdapConfig config) throws Exception {
+ // Initialize directory service
+ DefaultDirectoryServiceFactory factory = new DefaultDirectoryServiceFactory();
+ factory.init("scimple-embedded");
+ directoryService = factory.getDirectoryService();
+
+ // Derive the base DN from the user base DN (e.g. ou=users,dc=example,dc=com -> dc=example,dc=com)
+ String userBaseDn = config.getUserBaseDn();
+ String baseDn = userBaseDn.substring(userBaseDn.indexOf(',') + 1).trim();
+
+ // Add partition for the base DN
+ AvlPartition partition = new AvlPartition(directoryService.getSchemaManager());
+ partition.setId("scimple");
+ partition.setSuffixDn(new Dn(baseDn));
+ directoryService.addPartition(partition);
+
+ // Always bind to an ephemeral port — the config port is for external LDAP connections
+ try (ServerSocketChannel ch = ServerSocketChannel.open()) {
+ ch.bind(new InetSocketAddress(0));
+ port = ch.socket().getLocalPort();
+ }
+ host = InetAddress.getLoopbackAddress().getHostAddress();
+
+ // Start LDAP protocol server
+ ldapServer = new LdapServer();
+ ldapServer.setDirectoryService(directoryService);
+ ldapServer.setTransports(new TcpTransport(host, port));
+ ldapServer.start();
+
+ // Seed base directory structure and sample data
+ seedBaseEntries(config);
+ seedSampleData(config);
+
+ LOG.info("Embedded ApacheDS started on {}:{}", host, port);
+ }
+
+ /**
+ * Seeds the base directory structure: domain entry, user OU, and group OU.
+ *
+ * <p>Subclasses may override to customize the base directory structure. The default
+ * implementation creates the domain entry and organizational units derived from
+ * {@link ScimLdapConfig#getUserBaseDn()} and {@link ScimLdapConfig#getGroupBaseDn()}.</p>
+ *
+ * @param config the SCIM-LDAP configuration providing base DNs
+ * @throws Exception if entries cannot be created
+ */
+ protected void seedBaseEntries(ScimLdapConfig config)
+ throws Exception {
+ SchemaManager schemaManager = directoryService.getSchemaManager();
+ String userBaseDn = config.getUserBaseDn();
+ String baseDn = userBaseDn.substring(userBaseDn.indexOf(',') + 1).trim();
+ var session = directoryService.getAdminSession();
+
+ // Extract the domain component for the base entry (e.g. dc=example from dc=example,dc=com)
+ String dcValue = baseDn.split(",")[0].split("=")[1];
+
+ // Create base domain entry
+ session.add(new DefaultEntry(schemaManager,
+ baseDn,
+ "objectClass: domain",
+ "dc: " + dcValue));
+
+ // Create user OU
+ String userOu = userBaseDn.split(",")[0].split("=")[1];
+ session.add(new DefaultEntry(schemaManager,
+ userBaseDn,
+ "objectClass: organizationalUnit",
+ "ou: " + userOu));
+
+ // Create group OU
+ String groupBaseDn = config.getGroupBaseDn();
+ String groupOu = groupBaseDn.split(",")[0].split("=")[1];
+ session.add(new DefaultEntry(schemaManager,
+ groupBaseDn,
+ "objectClass: organizationalUnit",
+ "ou: " + groupOu));
+
+ LOG.debug("Seeded base entries: {}, {}, {}", baseDn, userBaseDn, groupBaseDn);
+ }
+
+ /**
+ * Seeds sample data into the directory for demonstration purposes.
+ *
+ * <p>Subclasses may override to provide custom seed data (e.g. test-specific entries)
+ * or to no-op if no sample data is desired.</p>
+ *
+ * @param config the SCIM-LDAP configuration providing base DNs
+ * @throws Exception if entries cannot be created
+ */
+ protected void seedSampleData(ScimLdapConfig config)
+ throws Exception {
+ SchemaManager schemaManager = directoryService.getSchemaManager();
+ var session = directoryService.getAdminSession();
+ String userBaseDn = config.getUserBaseDn();
+ String groupBaseDn = config.getGroupBaseDn();
+
+ // Sample users
+ session.add(new DefaultEntry(schemaManager,
+ "uid=bjensen," + userBaseDn,
+ "objectClass: inetOrgPerson",
+ "objectClass: organizationalPerson",
+ "objectClass: person",
+ "objectClass: top",
+ "uid: bjensen",
+ "cn: Barbara Jensen",
+ "sn: Jensen",
+ "givenName: Barbara",
+ "displayName: Barbara Jensen",
+ "mail: bjensen@example.com",
+ "title: Vice President"));
+
+ session.add(new DefaultEntry(schemaManager,
+ "uid=jsmith," + userBaseDn,
+ "objectClass: inetOrgPerson",
+ "objectClass: organizationalPerson",
+ "objectClass: person",
+ "objectClass: top",
+ "uid: jsmith",
+ "cn: John Smith",
+ "sn: Smith",
+ "givenName: John",
+ "displayName: John Smith",
+ "mail: jsmith@example.com",
+ "title: Engineer"));
+
+ session.add(new DefaultEntry(schemaManager,
+ "uid=awhite," + userBaseDn,
+ "objectClass: inetOrgPerson",
+ "objectClass: organizationalPerson",
+ "objectClass: person",
+ "objectClass: top",
+ "uid: awhite",
+ "cn: Alice White",
+ "sn: White",
+ "givenName: Alice",
+ "displayName: Alice White",
+ "mail: awhite@example.com",
+ "title: Manager"));
+
+ // Sample group with all users as members
+ session.add(new DefaultEntry(schemaManager,
+ "cn=Engineering," + groupBaseDn,
+ "objectClass: groupOfNames",
+ "objectClass: top",
+ "cn: Engineering",
+ "member: uid=bjensen," + userBaseDn,
+ "member: uid=jsmith," + userBaseDn,
+ "member: uid=awhite," + userBaseDn));
+
+ LOG.debug("Seeded sample users and groups");
+ }
+
+ /**
+ * Stops the embedded LDAP server and directory service.
+ *
+ * <p>Stops components in reverse order: LDAP protocol server first, then the
+ * underlying directory service. Safe to call even if the server was never started.</p>
+ */
+ public void stop() {
+ if (ldapServer != null) {
+ ldapServer.stop();
+ }
+ if (directoryService != null) {
+ try {
+ directoryService.shutdown();
+ } catch (Exception e) {
+ LOG.warn("Failed to shut down embedded directory service", e);
+ }
+ }
+ LOG.info("Embedded ApacheDS stopped");
+ }
+
+ /**
+ * Returns the underlying directory service, for subclasses that need to register custom
+ * schema attributes or add entries outside the standard seed methods.
+ *
+ * @return the directory service, or {@code null} if the server has not been started
+ */
+ protected DirectoryService getDirectoryService() { return directoryService; }
+
+ /** Returns the host the embedded server is listening on. */
+ public String getHost() { return host; }
+
+ /** Returns the port the embedded server is listening on. */
+ public int getPort() { return port; }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/LdapConnectionManager.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/LdapConnectionManager.java
new file mode 100644
index 00000000..2f290484
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/LdapConnectionManager.java
@@ -0,0 +1,144 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.ldap;
+
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.apache.directory.api.ldap.model.exception.LdapException;
+import org.apache.directory.ldap.client.api.LdapConnection;
+import org.apache.directory.ldap.client.api.LdapConnectionConfig;
+import org.apache.directory.ldap.client.api.LdapConnectionPool;
+import org.apache.directory.ldap.client.api.DefaultPoolableLdapConnectionFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Manages a pool of authenticated LDAP connections using the Apache Directory LDAP API.
+ *
+ * <p>This CDI bean is application-scoped: a single {@link LdapConnectionPool} is created at
+ * startup from {@link ScimLdapConfig} and shared across all request threads. When
+ * {@link ScimLdapConfig#isEmbedded()} is {@code true}, an {@link EmbeddedLdapServer} is
+ * started automatically before creating the connection pool.</p>
+ *
+ * <p>Callers obtain a connection with {@link #getConnection()} and must return it via
+ * {@link #releaseConnection(LdapConnection)} in a {@code finally} block.
+ */
+@ApplicationScoped
+public class LdapConnectionManager {
+
+ private static final Logger LOG = LoggerFactory.getLogger(LdapConnectionManager.class);
+
+ private LdapConnectionPool pool;
+ private EmbeddedLdapServer embeddedServer;
+
+ @Inject
+ ScimLdapConfig properties;
+
+ protected LdapConnectionManager() {}
+
+ @PostConstruct
+ void init() {
+ String host = properties.getHost();
+ int port = properties.getPort();
+ String bindDn = properties.getBindDn();
+
+ // Start embedded ApacheDS if configured
+ if (properties.isEmbedded()) {
+ try {
+ embeddedServer = new EmbeddedLdapServer();
+ embeddedServer.start(properties);
+ host = embeddedServer.getHost();
+ port = embeddedServer.getPort();
+ bindDn = "uid=admin,ou=system";
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to start embedded LDAP server", e);
+ }
+ }
+
+ try {
+ LdapConnectionConfig config = new LdapConnectionConfig();
+ config.setLdapHost(host);
+ config.setLdapPort(port);
+ config.setName(bindDn);
+ config.setCredentials(properties.isEmbedded() ? "secret" : properties.getBindPassword());
+ // When TLS is enabled, the connection uses the JVM's default trust store (javax.net.ssl.trustStore).
+ // Custom trust store configuration is not yet supported — configure via JVM system properties if needed.
+ if (properties.isUseTls()) {
+ config.setUseTls(true);
+ }
+
+ DefaultPoolableLdapConnectionFactory factory =
+ new DefaultPoolableLdapConnectionFactory(config);
+ pool = new LdapConnectionPool(factory);
+ LOG.info("LDAP connection pool initialized for {}:{} as {}", host, port, bindDn);
+ } catch (Exception e) {
+ throw new IllegalStateException(
+ "Failed to initialize LDAP connection pool for " + host + ":" + port + " as " + bindDn, e);
+ }
+ }
+
+ /**
+ * Borrows an authenticated {@link LdapConnection} from the pool.
+ *
+ * <p>The caller is responsible for returning the connection via
+ * {@link #releaseConnection(LdapConnection)} when finished.
+ *
+ * @return a pooled LDAP connection ready for use
+ * @throws LdapException if a connection cannot be obtained from the pool
+ */
+ public LdapConnection getConnection() throws LdapException {
+ return pool.getConnection();
+ }
+
+ /**
+ * Returns a previously borrowed {@link LdapConnection} to the pool.
+ *
+ * <p>If the connection is {@code null}, this method is a no-op. Any exception during
+ * release is logged at WARN level and swallowed so that it does not mask the original
+ * operation's result.
+ *
+ * @param connection the connection to return, or {@code null}
+ */
+ public void releaseConnection(LdapConnection connection) {
+ if (connection != null) {
+ try {
+ pool.releaseConnection(connection);
+ } catch (LdapException e) {
+ LOG.warn("Failed to release LDAP connection", e);
+ }
+ }
+ }
+
+ @PreDestroy
+ void close() {
+ if (pool != null) {
+ try {
+ pool.close();
+ } catch (Exception e) {
+ LOG.warn("Failed to close LDAP connection pool", e);
+ }
+ }
+ if (embeddedServer != null) {
+ embeddedServer.stop();
+ }
+ }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/LdapDao.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/LdapDao.java
new file mode 100644
index 00000000..33899b1d
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/LdapDao.java
@@ -0,0 +1,424 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.ldap;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.apache.directory.api.ldap.model.cursor.SearchCursor;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.Modification;
+import org.apache.directory.api.ldap.model.exception.LdapEntryAlreadyExistsException;
+import org.apache.directory.api.ldap.model.exception.LdapException;
+import org.apache.directory.api.ldap.model.exception.LdapNoSuchObjectException;
+import org.apache.directory.api.ldap.model.filter.EqualityNode;
+import org.apache.directory.api.ldap.model.filter.ExprNode;
+import org.apache.directory.api.ldap.model.message.SearchRequest;
+import org.apache.directory.api.ldap.model.message.SearchRequestImpl;
+import org.apache.directory.api.ldap.model.message.SearchScope;
+import org.apache.directory.api.ldap.model.message.controls.PagedResults;
+import org.apache.directory.api.ldap.model.message.controls.PagedResultsImpl;
+import org.apache.directory.api.ldap.model.name.Dn;
+import org.apache.directory.ldap.client.api.LdapConnection;
+import org.apache.directory.scim.ldap.mapping.FilterTranslator;
+import org.apache.directory.scim.spec.exception.ResourceException;
+import org.apache.directory.scim.spec.exception.ResourceNotFoundException;
+import org.apache.directory.scim.spec.exception.ConflictResourceException;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.FilterResponse;
+import org.apache.directory.scim.spec.filter.PageRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Data access object that performs LDAP CRUD operations on behalf of the SCIM resource providers.
+ *
+ * <p>Each public method obtains an {@link LdapConnection} from the
+ * {@link LdapConnectionManager}, executes the operation, and ensures the connection is
+ * released in a {@code finally} block. LDAP-specific exceptions are mapped to the
+ * appropriate {@link ResourceException} subclass (e.g.
+ * {@link ResourceNotFoundException}, {@link ConflictResourceException}).</p>
+ *
+ * <p>Search operations use the LDAP Simple Paged Results Control (RFC 2696) to
+ * iterate through large result sets in server-sized pages, rather than loading all
+ * matching entries into memory at once. SCIM pagination parameters ({@code startIndex}
+ * and {@code count}) are applied during iteration.</p>
+ */
+@ApplicationScoped
+public class LdapDao {
+
+ private static final Logger LOG = LoggerFactory.getLogger(LdapDao.class);
+
+ /** Default page size for the LDAP Simple Paged Results Control. */
+ private static final int LDAP_PAGE_SIZE = 100;
+
+ @Inject
+ LdapConnectionManager connectionManager;
+
+ @Inject
+ FilterTranslator filterTranslator;
+
+ @Inject
+ ScimLdapConfig config;
+
+ protected LdapDao() {}
+
+ /**
+ * Adds a new entry to the LDAP directory.
+ *
+ * @param entry the LDAP entry to create
+ * @throws ConflictResourceException if an entry with the same DN already exists
+ * @throws ResourceException if the LDAP operation fails for any other reason
+ */
+ public void create(Entry entry) throws ResourceException {
+ LdapConnection conn = null;
+ try {
+ conn = connectionManager.getConnection();
+ conn.add(entry);
+ } catch (LdapEntryAlreadyExistsException e) {
+ throw new ConflictResourceException("Entry already exists: " + entry.getDn(), e);
+ } catch (LdapException e) {
+ throw mapException(e);
+ } finally {
+ connectionManager.releaseConnection(conn);
+ }
+ }
+
+ /**
+ * Looks up an LDAP entry by its distinguished name, requesting all user and operational attributes.
+ *
+ * @param dn the distinguished name of the entry to retrieve
+ * @return the matching LDAP entry (never {@code null})
+ * @throws ResourceNotFoundException if no entry exists at the given DN
+ * @throws ResourceException if the LDAP operation fails
+ */
+ public Entry lookup(String dn) throws ResourceException {
+ LdapConnection conn = null;
+ try {
+ conn = connectionManager.getConnection();
+ Entry entry = conn.lookup(dn, "*", "+");
+ if (entry == null) {
+ throw new ResourceNotFoundException(dn);
+ }
+ return entry;
+ } catch (ResourceNotFoundException e) {
+ throw e;
+ } catch (LdapNoSuchObjectException e) {
+ throw new ResourceNotFoundException(dn, e);
+ } catch (LdapException e) {
+ throw mapException(e);
+ } finally {
+ connectionManager.releaseConnection(conn);
+ }
+ }
+
+ /**
+ * Searches for users matching a SCIM filter with pagination support.
+ *
+ * <p>Uses the LDAP Simple Paged Results Control (RFC 2696) to iterate through
+ * results in server-sized pages. SCIM pagination is applied during iteration:
+ * entries before {@code startIndex} are skipped without mapping, and iteration
+ * stops after {@code count} entries are collected.</p>
+ *
+ * @param scimFilter the SCIM filter, may be {@code null} for all users
+ * @param pageRequest the SCIM pagination parameters, may be {@code null}
+ * @return a {@link FilterResponse} with the requested page and correct {@code totalResults}
+ * @throws ResourceException if the LDAP search fails
+ */
+ public FilterResponse<Entry> findUsers(Filter scimFilter, PageRequest pageRequest) throws ResourceException {
+ ExprNode filter = filterTranslator.buildUserSearchFilter(scimFilter);
+ return pagedSearch(config.getUserBaseDn(), filter, pageRequest);
+ }
+
+ /**
+ * Searches for groups matching a SCIM filter with pagination support.
+ *
+ * @param scimFilter the SCIM filter, may be {@code null} for all groups
+ * @param pageRequest the SCIM pagination parameters, may be {@code null}
+ * @return a {@link FilterResponse} with the requested page and correct {@code totalResults}
+ * @throws ResourceException if the LDAP search fails
+ */
+ public FilterResponse<Entry> findGroups(Filter scimFilter, PageRequest pageRequest) throws ResourceException {
+ ExprNode filter = filterTranslator.buildGroupSearchFilter(scimFilter);
+ return pagedSearch(config.getGroupBaseDn(), filter, pageRequest);
+ }
+
+ /**
+ * Searches for a single LDAP entry matching an exact attribute value under the given base DN.
+ *
+ * @param baseDn the base DN from which to search
+ * @param attrName the LDAP attribute name to match
+ * @param attrValue the attribute value to match
+ * @return the first matching entry, or {@code null} if no match is found
+ * @throws ResourceException if the LDAP search fails
+ */
+ public Entry searchByAttribute(String baseDn, String attrName, String attrValue) throws ResourceException {
+ ExprNode filter = new EqualityNode<>(attrName, attrValue);
+ List<Entry> results = searchAll(baseDn, filter);
+ if (results.isEmpty()) {
+ return null;
+ }
+ return results.get(0);
+ }
+
+ /**
+ * Applies one or more modifications to an existing LDAP entry.
+ *
+ * @param dn the distinguished name of the entry to modify
+ * @param modifications the modifications to apply
+ * @throws ResourceNotFoundException if no entry exists at the given DN
+ * @throws ResourceException if the LDAP operation fails
+ */
+ public void modify(String dn, Modification... modifications) throws ResourceException {
+ LdapConnection conn = null;
+ try {
+ conn = connectionManager.getConnection();
+ conn.modify(dn, modifications);
+ } catch (LdapNoSuchObjectException e) {
+ throw new ResourceNotFoundException(dn, e);
+ } catch (LdapException e) {
+ throw mapException(e);
+ } finally {
+ connectionManager.releaseConnection(conn);
+ }
+ }
+
+ /**
+ * Renames an LDAP entry by replacing its RDN.
+ *
+ * <p>Uses {@code deleteOldRdn=true} so the old RDN attribute value is removed
+ * from the entry after the rename, keeping the entry consistent with its new DN.</p>
+ *
+ * <p><strong>Note:</strong> LDAP does not support multi-operation transactions
+ * (RFC 4511). Callers that rename and then modify in sequence accept the risk
+ * that a modify failure after a successful rename leaves the entry at the new
+ * DN with its attributes only partially updated.</p>
+ *
+ * @param dn the current DN of the entry
+ * @param newRdn the new RDN string, e.g. {@code "uid=jsmith"}
+ * @throws ResourceNotFoundException if no entry exists at {@code dn}
+ * @throws ConflictResourceException if an entry already exists at the new DN
+ * @throws ResourceException if the LDAP operation fails
+ */
+ public void rename(String dn, String newRdn) throws ResourceException {
+ LdapConnection conn = null;
+ try {
+ conn = connectionManager.getConnection();
+ conn.rename(dn, newRdn, true); // deleteOldRdn = true
+ } catch (LdapEntryAlreadyExistsException e) {
+ throw new ConflictResourceException("Entry already exists: " + newRdn, e);
+ } catch (LdapNoSuchObjectException e) {
+ throw new ResourceNotFoundException(dn, e);
+ } catch (LdapException e) {
+ throw mapException(e);
+ } finally {
+ connectionManager.releaseConnection(conn);
+ }
+ }
+
+ /**
+ * Deletes an LDAP entry by its distinguished name.
+ *
+ * @param dn the distinguished name of the entry to delete
+ * @throws ResourceNotFoundException if no entry exists at the given DN
+ * @throws ResourceException if the LDAP operation fails
+ */
+ public void delete(String dn) throws ResourceException {
+ LdapConnection conn = null;
+ try {
+ conn = connectionManager.getConnection();
+ conn.delete(dn);
+ } catch (LdapNoSuchObjectException e) {
+ throw new ResourceNotFoundException(dn, e);
+ } catch (LdapException e) {
+ throw mapException(e);
+ } finally {
+ connectionManager.releaseConnection(conn);
+ }
+ }
+
+ /**
+ * Executes a paged LDAP search using the Simple Paged Results Control (RFC 2696).
+ *
+ * <p>This method handles SCIM-to-LDAP pagination translation:</p>
+ * <ul>
+ * <li>Iterates through LDAP result pages of {@value #LDAP_PAGE_SIZE} entries each</li>
+ * <li>Counts all matching entries to produce an accurate {@code totalResults}</li>
+ * <li>Skips entries before the SCIM {@code startIndex} without processing them</li>
+ * <li>Collects up to {@code count} entries for the requested page</li>
+ * <li>Continues iterating after the page is full to get the correct total count</li>
+ * </ul>
+ *
+ * <p>The LDAP paged results control is forward-only (cookie-based), so random
+ * access to arbitrary pages requires iterating from the beginning. For large
+ * directories, consider LDAP-side filtering to reduce the result set.</p>
+ *
+ * @param baseDn the base DN to search under
+ * @param filter the LDAP filter (ExprNode)
+ * @param pageRequest SCIM pagination parameters (1-based startIndex + count), may be {@code null}
+ * @return a {@link FilterResponse} containing the requested page of entries and the total count
+ */
+ private FilterResponse<Entry> pagedSearch(String baseDn, ExprNode filter, PageRequest pageRequest)
+ throws ResourceException {
+
+ // --- SCIM Pagination (RFC 7644 §3.4.2.4) ---
+ //
+ // SCIM uses 1-based indexing:
+ // startIndex=1 means "start from the first result"
+ // startIndex=11, count=10 means "return results 11-20"
+ //
+ // We convert to 0-based skip/limit for iteration.
+ // totalResults MUST be the total number of matching entries, not the page size.
+ long skip = 0;
+ long limit = Long.MAX_VALUE;
+
+ if (pageRequest != null) {
+ if (pageRequest.getStartIndex() != null) {
+ // Convert SCIM 1-based startIndex to 0-based skip count
+ skip = Math.max(0, pageRequest.getStartIndex() - 1L);
+ }
+ if (pageRequest.getCount() != null) {
+ limit = pageRequest.getCount();
+ }
+ }
+
+ // --- LDAP Simple Paged Results Control (RFC 2696) ---
+ //
+ // Instead of fetching all matching entries at once (which can overwhelm memory
+ // for large directories), we request entries in pages of LDAP_PAGE_SIZE.
+ //
+ // The control uses a cookie-based iteration model:
+ // 1. First request: send PagedResultsControl with size=LDAP_PAGE_SIZE, empty cookie
+ // 2. Server returns up to LDAP_PAGE_SIZE entries + a cookie
+ // 3. Next request: send the cookie back to get the next page
+ // 4. Repeat until the server returns an empty cookie (no more results)
+ //
+ // We iterate through ALL pages to get an accurate totalResults count,
+ // but only collect entries that fall within the requested SCIM page.
+
+ List<Entry> pageEntries = new ArrayList<>();
+ int totalResults = 0;
+
+ LdapConnection conn = null;
+ try {
+ conn = connectionManager.getConnection();
+ byte[] cookie = null;
+
+ do {
+ SearchRequest request = new SearchRequestImpl();
+ request.setBase(new Dn(baseDn));
+ request.setScope(SearchScope.SUBTREE);
+ request.setFilter(filter);
+ request.addAttributes("*", "+");
+
+ // Add the paged results control with the current cookie
+ PagedResults pagedControl = new PagedResultsImpl();
+ pagedControl.setSize(LDAP_PAGE_SIZE);
+ if (cookie != null) {
+ pagedControl.setCookie(cookie);
+ }
+ request.addControl(pagedControl);
+
+ try (SearchCursor cursor = conn.search(request)) {
+ while (cursor.next()) {
+ if (cursor.isEntry()) {
+ // Count every matching entry for totalResults
+ totalResults++;
+
+ // Collect entries that fall within the requested SCIM page:
+ // - Skip the first 'skip' entries (before startIndex)
+ // - Collect up to 'limit' entries (the page)
+ // - Continue counting after the page for totalResults
+ if (totalResults > skip && pageEntries.size() < limit) {
+ pageEntries.add(cursor.getEntry());
+ }
+ }
+ }
+
+ // Extract the cookie from the response for the next page
+ // An empty cookie (length 0) means no more results
+ if (cursor.getSearchResultDone() != null) {
+ PagedResults responseControl = (PagedResults) cursor.getSearchResultDone()
+ .getControl(PagedResults.OID);
+ if (responseControl != null && responseControl.getCookie() != null
+ && responseControl.getCookie().length > 0) {
+ cookie = responseControl.getCookie();
+ } else {
+ cookie = null; // No more pages
+ }
+ } else {
+ cookie = null;
+ }
+ }
+ } while (cookie != null);
+
+ return new FilterResponse<>(pageEntries, totalResults);
+ } catch (LdapException e) {
+ throw mapException(e);
+ } catch (Exception e) {
+ throw mapException(e);
+ } finally {
+ connectionManager.releaseConnection(conn);
+ }
+ }
+
+ /**
+ * Searches for all entries matching the filter (no pagination). Used internally
+ * for simple lookups like {@link #searchByAttribute}.
+ */
+ private List<Entry> searchAll(String baseDn, ExprNode filter) throws ResourceException {
+ LdapConnection conn = null;
+ try {
+ conn = connectionManager.getConnection();
+
+ SearchRequest request = new SearchRequestImpl();
+ request.setBase(new Dn(baseDn));
+ request.setScope(SearchScope.SUBTREE);
+ request.setFilter(filter);
+ request.addAttributes("*", "+");
+
+ List<Entry> results = new ArrayList<>();
+ try (SearchCursor cursor = conn.search(request)) {
+ while (cursor.next()) {
+ if (cursor.isEntry()) {
+ results.add(cursor.getEntry());
+ }
+ }
+ }
+ return results;
+ } catch (LdapException e) {
+ throw mapException(e);
+ } catch (Exception e) {
+ throw mapException(e);
+ } finally {
+ connectionManager.releaseConnection(conn);
+ }
+ }
+
+ private ResourceException mapException(Exception e) {
+ if (e instanceof ResourceException re) {
+ return re;
+ }
+ LOG.error("LDAP operation failed", e);
+ return new ResourceException(500, "LDAP operation failed");
+ }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/ScimLdapConfig.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/ScimLdapConfig.java
new file mode 100644
index 00000000..1d211889
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/ldap/ScimLdapConfig.java
@@ -0,0 +1,228 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.ldap;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Central configuration for the SCIM-to-LDAP bridge, loaded from {@code scim-ldap.yml} on the
+ * classpath. Holds LDAP connection settings (host, port, bind DN, TLS) and SCIM-to-LDAP
+ * attribute mappings for users and groups.
+ *
+ * <p>LDAP connection settings can be overridden at runtime via system properties such as
+ * {@code ldap.host}, {@code ldap.port}, {@code ldap.bind.dn}, {@code ldap.bind.password},
+ * {@code ldap.base.dn.users}, {@code ldap.base.dn.groups}, and {@code ldap.use.tls}.
+ * System properties take precedence over YAML values, which take precedence over built-in defaults.
+ */
+@ApplicationScoped
+public class ScimLdapConfig {
+
+ private static final Logger LOG = LoggerFactory.getLogger(ScimLdapConfig.class);
+ private static final String CONFIG_FILE = "scim-ldap.yml";
+
+ private String host = "localhost";
+ private int port = 389;
+ private String bindDn = "cn=admin,dc=example,dc=com";
+ private String bindPassword = "secret";
+ private String userBaseDn = "ou=users,dc=example,dc=com";
+ private String groupBaseDn = "ou=groups,dc=example,dc=com";
+ private boolean useTls = false;
+ private boolean embedded = false;
+
+ private List<String> userObjectClasses;
+ private String userRdnAttribute;
+ private Map<String, String> userAttributes;
+
+ private List<String> groupObjectClasses;
+ private String groupRdnAttribute;
+ private Map<String, String> groupAttributes;
+
+ protected ScimLdapConfig() {}
+
+ /**
+ * Creates a configuration with explicit LDAP connection settings, primarily for
+ * programmatic or test use where loading from {@code scim-ldap.yml} is not desired.
+ *
+ * @param host the LDAP server hostname
+ * @param port the LDAP server port
+ * @param bindDn the DN used to bind (authenticate) to the LDAP server
+ * @param bindPassword the password for the bind DN
+ * @param userBaseDn the base DN under which user entries are stored
+ * @param groupBaseDn the base DN under which group entries are stored
+ * @param useTls {@code true} to enable TLS for LDAP connections
+ */
+ public ScimLdapConfig(String host, int port, String bindDn, String bindPassword,
+ String userBaseDn, String groupBaseDn, boolean useTls) {
+ this.host = host;
+ this.port = port;
+ this.bindDn = bindDn;
+ this.bindPassword = bindPassword;
+ this.userBaseDn = userBaseDn;
+ this.groupBaseDn = groupBaseDn;
+ this.useTls = useTls;
+ }
+
+ @SuppressWarnings("unchecked")
+ @PostConstruct
+ protected void init() {
+ Map<String, Object> config = loadYaml();
+
+ // LDAP connection settings (YAML values, then system property overrides)
+ Map<String, Object> ldap = (Map<String, Object>) config.getOrDefault("ldap", Collections.emptyMap());
+ host = resolveString("ldap.host", ldap, "host", host);
+ bindDn = resolveString("ldap.bind.dn", ldap, "bindDn", bindDn);
+ bindPassword = resolveString("ldap.bind.password", ldap, "bindPassword", bindPassword);
+ userBaseDn = resolveString("ldap.base.dn.users", ldap, "userBaseDn", userBaseDn);
+ groupBaseDn = resolveString("ldap.base.dn.groups", ldap, "groupBaseDn", groupBaseDn);
+ useTls = Boolean.parseBoolean(resolveString("ldap.use.tls", ldap, "useTls", String.valueOf(useTls)));
+ embedded = Boolean.parseBoolean(resolveString("ldap.embedded", ldap, "embedded", String.valueOf(embedded)));
+
+ String portStr = resolveString("ldap.port", ldap, "port", String.valueOf(port));
+ try {
+ port = Integer.parseInt(portStr.trim());
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException(
+ "Invalid ldap.port value: '" + portStr + "'. Must be a valid integer.", e);
+ }
+
+ // User mapping
+ Map<String, Object> user = (Map<String, Object>) config.getOrDefault("user", Collections.emptyMap());
+ userObjectClasses = (List<String>) user.getOrDefault("objectClasses",
+ List.of("inetOrgPerson", "organizationalPerson", "person", "top"));
+ userRdnAttribute = (String) user.getOrDefault("rdnAttribute", "uid");
+ userAttributes = new LinkedHashMap<>((Map<String, String>) user.getOrDefault("attributes", defaultUserAttributes()));
+
+ // Group mapping
+ Map<String, Object> group = (Map<String, Object>) config.getOrDefault("group", Collections.emptyMap());
+ groupObjectClasses = (List<String>) group.getOrDefault("objectClasses",
+ List.of("groupOfNames", "top"));
+ groupRdnAttribute = (String) group.getOrDefault("rdnAttribute", "cn");
+ groupAttributes = new LinkedHashMap<>((Map<String, String>) group.getOrDefault("attributes", defaultGroupAttributes()));
+
+ LOG.debug("LDAP configuration: host={}, port={}, bindDn={}, userBaseDn={}, groupBaseDn={}, useTls={}, embedded={}",
+ host, port, bindDn, userBaseDn, groupBaseDn, useTls, embedded);
+
+ if (!useTls && bindPassword != null && !bindPassword.isEmpty()) {
+ LOG.warn("TLS is disabled — bind credentials will be sent in plaintext. Set ldap.use-tls=true for production.");
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map<String, Object> loadYaml() {
+ try (InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(CONFIG_FILE)) {
+ if (is == null) {
+ LOG.warn("No {} found on classpath, using defaults", CONFIG_FILE);
+ return Collections.emptyMap();
+ }
+ ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
+ Map<String, Object> config = yaml.readValue(is, Map.class);
+ LOG.info("Loaded configuration from {}", CONFIG_FILE);
+ return config;
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to load " + CONFIG_FILE, e);
+ }
+ }
+
+ private static String resolveString(String sysPropKey, Map<String, Object> yamlSection,
+ String yamlKey, String defaultValue) {
+ String yamlValue = yamlSection.containsKey(yamlKey)
+ ? String.valueOf(yamlSection.get(yamlKey))
+ : defaultValue;
+ return System.getProperty(sysPropKey, yamlValue);
+ }
+
+ private static Map<String, String> defaultUserAttributes() {
+ Map<String, String> attrs = new LinkedHashMap<>();
+ attrs.put("userName", "uid");
+ attrs.put("name.givenName", "givenName");
+ attrs.put("name.familyName", "sn");
+ attrs.put("name.formatted", "cn");
+ attrs.put("displayName", "displayName");
+ attrs.put("emails.value", "mail");
+ attrs.put("phoneNumbers.value", "telephoneNumber");
+ attrs.put("addresses.streetAddress", "street");
+ attrs.put("addresses.locality", "l");
+ attrs.put("addresses.postalCode", "postalCode");
+ attrs.put("title", "title");
+ attrs.put("userType", "employeeType");
+ attrs.put("password", "userPassword");
+ return attrs;
+ }
+
+ private static Map<String, String> defaultGroupAttributes() {
+ Map<String, String> attrs = new LinkedHashMap<>();
+ attrs.put("displayName", "cn");
+ attrs.put("members.value", "member");
+ return attrs;
+ }
+
+ /** Returns the LDAP server hostname. */
+ public String getHost() { return host; }
+
+ /** Returns the LDAP server port. */
+ public int getPort() { return port; }
+
+ /** Returns the DN used to bind to the LDAP server. */
+ public String getBindDn() { return bindDn; }
+
+ /** Returns the password for the bind DN. */
+ public String getBindPassword() { return bindPassword; }
+
+ /** Returns the base DN under which user entries are stored. */
+ public String getUserBaseDn() { return userBaseDn; }
+
+ /** Returns the base DN under which group entries are stored. */
+ public String getGroupBaseDn() { return groupBaseDn; }
+
+ /** Returns {@code true} if TLS is enabled for LDAP connections. */
+ public boolean isUseTls() { return useTls; }
+
+ /** Returns {@code true} if an embedded ApacheDS server should be started. */
+ public boolean isEmbedded() { return embedded; }
+
+ /** Returns the LDAP object classes used when creating user entries. */
+ public List<String> getUserObjectClasses() { return userObjectClasses; }
+
+ /** Returns the RDN attribute name for user entries (e.g. {@code uid}). */
+ public String getUserRdnAttribute() { return userRdnAttribute; }
+
+ /** Returns the SCIM-to-LDAP attribute mapping for users, keyed by SCIM attribute path. */
+ public Map<String, String> getUserAttributes() { return userAttributes; }
+
+ /** Returns the LDAP object classes used when creating group entries. */
+ public List<String> getGroupObjectClasses() { return groupObjectClasses; }
+
+ /** Returns the RDN attribute name for group entries (e.g. {@code cn}). */
+ public String getGroupRdnAttribute() { return groupRdnAttribute; }
+
+ /** Returns the SCIM-to-LDAP attribute mapping for groups, keyed by SCIM attribute path. */
+ public Map<String, String> getGroupAttributes() { return groupAttributes; }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/mapping/AttributeMapper.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/mapping/AttributeMapper.java
new file mode 100644
index 00000000..706ba73b
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/mapping/AttributeMapper.java
@@ -0,0 +1,573 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.mapping;
+
+import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.apache.directory.api.ldap.model.entry.Attribute;
+import org.apache.directory.api.ldap.model.entry.DefaultEntry;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.Value;
+import org.apache.directory.api.ldap.model.exception.LdapException;
+import org.apache.directory.api.ldap.model.exception.LdapInvalidAttributeValueException;
+import org.apache.directory.api.ldap.model.name.Dn;
+import org.apache.directory.api.ldap.model.name.Rdn;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.spec.resources.Address;
+import org.apache.directory.scim.spec.resources.Email;
+import org.apache.directory.scim.spec.resources.GroupMembership;
+import org.apache.directory.scim.spec.resources.Name;
+import org.apache.directory.scim.spec.resources.PhoneNumber;
+import org.apache.directory.scim.spec.resources.ScimGroup;
+import org.apache.directory.scim.spec.resources.ScimUser;
+import org.apache.directory.scim.spec.schema.Meta;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.format.DateTimeParseException;
+import java.time.temporal.ChronoField;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Converts between SCIM resources ({@link ScimUser}, {@link ScimGroup}) and Apache Directory
+ * LDAP {@link Entry} objects using configurable attribute mappings from {@link ScimLdapConfig}.
+ *
+ * <p>This CDI bean reads its SCIM-to-LDAP attribute mapping at startup and provides
+ * bidirectional conversion methods as well as lookup helpers used by
+ * {@link FilterTranslator} to resolve SCIM attribute paths to their LDAP counterparts.
+ * DN construction follows RFC 4514 escaping via {@link Rdn}.</p>
+ */
+@ApplicationScoped
+public class AttributeMapper {
+
+ private static final Logger LOG = LoggerFactory.getLogger(AttributeMapper.class);
+
+ /**
+ * Formatter for LDAP GeneralizedTime (RFC 4517), e.g. {@code "20240115143000Z"} or
+ * {@code "20240115143000.12Z"}. {@code appendFraction} handles 1–3 fractional-second
+ * digits uniformly; a simple pattern string cannot do this correctly.
+ */
+ private static final java.time.format.DateTimeFormatter GENERALIZED_TIME =
+ new DateTimeFormatterBuilder()
+ .appendPattern("yyyyMMddHHmmss")
+ .optionalStart()
+ .appendFraction(ChronoField.NANO_OF_SECOND, 1, 3, true)
+ .optionalEnd()
+ .appendPattern("X")
+ .toFormatter();
+
+ @Inject
+ ScimLdapConfig properties;
+
+ private Map<String, String> scimToLdapUser;
+ private Map<String, String> scimToLdapGroup;
+
+ private List<String> userObjectClasses;
+ private String userRdnAttribute;
+ private List<String> groupObjectClasses;
+ private String groupRdnAttribute;
+
+ protected AttributeMapper() {}
+
+ @PostConstruct
+ void init() {
+ userObjectClasses = properties.getUserObjectClasses();
+ userRdnAttribute = properties.getUserRdnAttribute();
+ scimToLdapUser = new LinkedHashMap<>(properties.getUserAttributes());
+
+ groupObjectClasses = properties.getGroupObjectClasses();
+ groupRdnAttribute = properties.getGroupRdnAttribute();
+ scimToLdapGroup = new LinkedHashMap<>(properties.getGroupAttributes());
+
+ LOG.info("Attribute mapping initialized: user objectClasses={}, group objectClasses={}",
+ userObjectClasses, groupObjectClasses);
+ }
+
+ /**
+ * Maps an LDAP {@link Entry} to a {@link ScimUser}, populating standard SCIM core
+ * attributes (userName, name, displayName, emails, phoneNumbers, addresses, title,
+ * userType, and active) according to the configured attribute mapping.
+ *
+ * <p>The SCIM {@code id} is taken from the LDAP operational attribute {@code entryUUID}.</p>
+ *
+ * @param entry the LDAP entry to convert
+ * @return a populated {@link ScimUser}; fields for which no LDAP value exists are left unset
+ * @throws LdapInvalidAttributeValueException if an LDAP attribute value cannot be read
+ */
+ public ScimUser toScimUser(Entry entry) throws LdapInvalidAttributeValueException {
+ ScimUser user = new ScimUser();
+
+ // id from entryUUID (operational attribute)
+ String entryUuid = getStringAttribute(entry, "entryUUID");
+ if (entryUuid != null) {
+ user.setId(entryUuid);
+ }
+
+ // userName
+ String uid = getStringAttribute(entry, scimToLdapUser.get("userName"));
+ if (uid != null) {
+ user.setUserName(uid);
+ }
+
+ // name
+ Name name = new Name();
+ boolean hasName = false;
+ String givenName = getStringAttribute(entry, scimToLdapUser.get("name.givenName"));
+ if (givenName != null) {
+ name.setGivenName(givenName);
+ hasName = true;
+ }
+ String familyName = getStringAttribute(entry, scimToLdapUser.get("name.familyName"));
+ if (familyName != null) {
+ name.setFamilyName(familyName);
+ hasName = true;
+ }
+ String formatted = getStringAttribute(entry, scimToLdapUser.get("name.formatted"));
+ if (formatted != null) {
+ name.setFormatted(formatted);
+ hasName = true;
+ }
+ if (hasName) {
+ user.setName(name);
+ }
+
+ // displayName
+ String displayName = getStringAttribute(entry, scimToLdapUser.get("displayName"));
+ if (displayName != null) {
+ user.setDisplayName(displayName);
+ }
+
+ // emails (multi-valued)
+ List<Email> emails = getMultiValuedAsEmails(entry, scimToLdapUser.get("emails.value"));
+ if (!emails.isEmpty()) {
+ user.setEmails(emails);
+ }
+
+ // phoneNumbers (multi-valued)
+ List<PhoneNumber> phones = getMultiValuedAsPhoneNumbers(entry, scimToLdapUser.get("phoneNumbers.value"));
+ if (!phones.isEmpty()) {
+ user.setPhoneNumbers(phones);
+ }
+
+ // addresses
+ Address address = new Address();
+ boolean hasAddress = false;
+ String street = getStringAttribute(entry, scimToLdapUser.get("addresses.streetAddress"));
+ if (street != null) {
+ address.setStreetAddress(street);
+ hasAddress = true;
+ }
+ String locality = getStringAttribute(entry, scimToLdapUser.get("addresses.locality"));
+ if (locality != null) {
+ address.setLocality(locality);
+ hasAddress = true;
+ }
+ String postalCode = getStringAttribute(entry, scimToLdapUser.get("addresses.postalCode"));
+ if (postalCode != null) {
+ address.setPostalCode(postalCode);
+ hasAddress = true;
+ }
+ if (hasAddress) {
+ user.setAddresses(List.of(address));
+ }
+
+ // title
+ String title = getStringAttribute(entry, scimToLdapUser.get("title"));
+ if (title != null) {
+ user.setTitle(title);
+ }
+
+ // userType
+ String userType = getStringAttribute(entry, scimToLdapUser.get("userType"));
+ if (userType != null) {
+ user.setUserType(userType);
+ }
+
+ // active (stored as string "true"/"false" in custom attribute)
+ String activeStr = getStringAttribute(entry, "scimActive");
+ user.setActive(activeStr == null || Boolean.parseBoolean(activeStr));
+
+ // meta — version (ETag), created, lastModified from LDAP operational attributes
+ String modifyTs = getStringAttribute(entry, "modifyTimestamp");
+ String createTs = getStringAttribute(entry, "createTimestamp");
+ Meta meta = new Meta()
+ .setResourceType("User")
+ .setCreated(parseLdapTimestamp(createTs))
+ .setLastModified(parseLdapTimestamp(modifyTs));
+ // Fall back to createTimestamp when entry has never been modified
+ String etagSource = modifyTs != null ? modifyTs : createTs;
+ if (etagSource != null) {
+ meta.setVersion("W/\"" + etagSource + "\"");
+ }
+ user.setMeta(meta);
+
+ return user;
+ }
+
+ /**
+ * Converts a {@link ScimUser} into an LDAP {@link Entry} suitable for an add operation.
+ *
+ * <p>The entry's DN is constructed from the configured RDN attribute and the user's
+ * {@code userName}, placed under the given {@code baseDn}. Required
+ * {@code inetOrgPerson} attributes ({@code sn}, {@code cn}) are filled with sensible
+ * defaults when not explicitly mapped. Phone-number type metadata is stored in a
+ * custom {@code scimPhoneTypes} attribute for round-trip fidelity.</p>
+ *
+ * @param user the SCIM user to convert
+ * @param baseDn the LDAP base DN under which the entry will be created
+ * @return a fully populated {@link Entry} ready to be added to the directory
+ * @throws LdapException if the DN cannot be constructed or an attribute is invalid
+ * @throws IllegalArgumentException if the user's {@code userName} is {@code null} or blank
+ */
+ public Entry toEntry(ScimUser user, String baseDn) throws LdapException {
+ String rdnValue = user.getUserName();
+ if (rdnValue == null || rdnValue.isBlank()) {
+ throw new IllegalArgumentException("userName is required to create an LDAP entry");
+ }
+
+ Dn dn = buildDn(userRdnAttribute, rdnValue, baseDn);
+ Entry entry = new DefaultEntry(dn);
+ entry.add("objectClass", userObjectClasses.toArray(new String[0]));
+
+ entry.add(scimToLdapUser.get("userName"), user.getUserName());
+
+ if (user.getName() != null) {
+ if (user.getName().getGivenName() != null) {
+ entry.add(scimToLdapUser.get("name.givenName"), user.getName().getGivenName());
+ }
+ if (user.getName().getFamilyName() != null) {
+ entry.add(scimToLdapUser.get("name.familyName"), user.getName().getFamilyName());
+ }
+ if (user.getName().getFormatted() != null) {
+ entry.add(scimToLdapUser.get("name.formatted"), user.getName().getFormatted());
+ }
+ }
+
+ // Ensure required inetOrgPerson attributes
+ if (!entry.containsAttribute("sn")) {
+ entry.add("sn", user.getUserName());
+ }
+ if (!entry.containsAttribute("cn")) {
+ String cn = user.getDisplayName() != null ? user.getDisplayName() : user.getUserName();
+ entry.add("cn", cn);
+ }
+
+ if (user.getDisplayName() != null) {
+ entry.add(scimToLdapUser.get("displayName"), user.getDisplayName());
+ }
+
+ if (user.getEmails() != null) {
+ for (Email email : user.getEmails()) {
+ if (email.getValue() != null) {
+ entry.add(scimToLdapUser.get("emails.value"), email.getValue());
+ }
+ }
+ }
+
+ if (user.getPhoneNumbers() != null) {
+ for (PhoneNumber phone : user.getPhoneNumbers()) {
+ if (phone.getValue() != null) {
+ entry.add(scimToLdapUser.get("phoneNumbers.value"), phone.getValue());
+ }
+ }
+ // Store phone type metadata in a custom attribute for round-trip fidelity
+ StringBuilder phoneTypes = new StringBuilder();
+ for (PhoneNumber phone : user.getPhoneNumbers()) {
+ if (phoneTypes.length() > 0) {
+ phoneTypes.append(",");
+ }
+ phoneTypes.append(phone.getType() != null ? phone.getType() : "");
+ }
+ entry.add("scimPhoneTypes", phoneTypes.toString());
+ }
+
+ if (user.getAddresses() != null && !user.getAddresses().isEmpty()) {
+ Address addr = user.getAddresses().get(0);
+ if (addr.getStreetAddress() != null) {
+ entry.add(scimToLdapUser.get("addresses.streetAddress"), addr.getStreetAddress());
+ }
+ if (addr.getLocality() != null) {
+ entry.add(scimToLdapUser.get("addresses.locality"), addr.getLocality());
+ }
+ if (addr.getPostalCode() != null) {
+ entry.add(scimToLdapUser.get("addresses.postalCode"), addr.getPostalCode());
+ }
+ }
+
+ if (user.getTitle() != null) {
+ entry.add(scimToLdapUser.get("title"), user.getTitle());
+ }
+ if (user.getUserType() != null) {
+ entry.add(scimToLdapUser.get("userType"), user.getUserType());
+ }
+ if (user.getPassword() != null) {
+ entry.add(scimToLdapUser.get("password"), user.getPassword());
+ }
+
+ // active flag (custom attribute)
+ if (user.getActive() != null) {
+ entry.add("scimActive", user.getActive().toString());
+ }
+
+ return entry;
+ }
+
+ /**
+ * Maps an LDAP {@link Entry} to a {@link ScimGroup}, populating the display name and
+ * group members according to the configured attribute mapping.
+ *
+ * <p>Member DNs are stored as {@link GroupMembership#setValue(String) membership values};
+ * the repository layer is responsible for resolving them to {@code entryUUID} identifiers
+ * if needed. Empty member placeholders (used by some LDAP servers for
+ * {@code groupOfNames} compliance) are skipped.</p>
+ *
+ * @param entry the LDAP entry to convert
+ * @return a populated {@link ScimGroup}
+ * @throws LdapInvalidAttributeValueException if an LDAP attribute value cannot be read
+ */
+ public ScimGroup toScimGroup(Entry entry) throws LdapInvalidAttributeValueException {
+ ScimGroup group = new ScimGroup();
+
+ String entryUuid = getStringAttribute(entry, "entryUUID");
+ if (entryUuid != null) {
+ group.setId(entryUuid);
+ }
+
+ String displayName = getStringAttribute(entry, scimToLdapGroup.get("displayName"));
+ if (displayName != null) {
+ group.setDisplayName(displayName);
+ }
+
+ // members (multi-valued DNs)
+ String memberAttr = scimToLdapGroup.get("members.value");
+ Attribute members = entry.get(memberAttr);
+ if (members != null) {
+ List<GroupMembership> memberList = new ArrayList<>();
+ for (Value val : members) {
+ String memberDn = val.getString();
+ // Skip empty member placeholder used by some LDAP servers
+ if (memberDn != null && !memberDn.isBlank()) {
+ GroupMembership membership = new GroupMembership();
+ // Store the DN as the value; the repository will resolve to entryUUID if needed
+ membership.setValue(memberDn);
+ memberList.add(membership);
+ }
+ }
+ group.setMembers(memberList);
+ }
+
+ // meta — version (ETag), created, lastModified from LDAP operational attributes
+ String modifyTs = getStringAttribute(entry, "modifyTimestamp");
+ String createTs = getStringAttribute(entry, "createTimestamp");
+ Meta meta = new Meta()
+ .setResourceType("Group")
+ .setCreated(parseLdapTimestamp(createTs))
+ .setLastModified(parseLdapTimestamp(modifyTs));
+ String etagSource = modifyTs != null ? modifyTs : createTs;
+ if (etagSource != null) {
+ meta.setVersion("W/\"" + etagSource + "\"");
+ }
+ group.setMeta(meta);
+
+ return group;
+ }
+
+ /**
+ * Converts a {@link ScimGroup} into an LDAP {@link Entry} suitable for an add operation.
+ *
+ * <p>The entry's DN is constructed from the configured group RDN attribute and the
+ * group's {@code displayName}, placed under the given {@code baseDn}. If the group
+ * has no members, an empty placeholder value is added to satisfy the
+ * {@code groupOfNames} schema requirement for at least one {@code member} attribute.</p>
+ *
+ * @param group the SCIM group to convert
+ * @param baseDn the LDAP base DN under which the entry will be created
+ * @return a fully populated {@link Entry} ready to be added to the directory
+ * @throws LdapException if the DN cannot be constructed or an attribute is invalid
+ * @throws IllegalArgumentException if the group's {@code displayName} is {@code null} or blank
+ */
+ public Entry toEntry(ScimGroup group, String baseDn) throws LdapException {
+ String rdnValue = group.getDisplayName();
+ if (rdnValue == null || rdnValue.isBlank()) {
+ throw new IllegalArgumentException("displayName is required to create an LDAP group entry");
+ }
+
+ Dn dn = buildDn(groupRdnAttribute, rdnValue, baseDn);
+ Entry entry = new DefaultEntry(dn);
+ entry.add("objectClass", groupObjectClasses.toArray(new String[0]));
+ entry.add(scimToLdapGroup.get("displayName"), group.getDisplayName());
+
+ if (group.getMembers() != null && !group.getMembers().isEmpty()) {
+ for (GroupMembership member : group.getMembers()) {
+ if (member.getValue() != null) {
+ entry.add(scimToLdapGroup.get("members.value"), member.getValue());
+ }
+ }
+ } else {
+ // groupOfNames requires at least one member; use a placeholder
+ entry.add(scimToLdapGroup.get("members.value"), "");
+ }
+
+ return entry;
+ }
+
+ /**
+ * Resolves a SCIM user attribute path (e.g. {@code "userName"} or {@code "name.givenName"})
+ * to the corresponding LDAP attribute name.
+ *
+ * @param scimAttribute the SCIM attribute path
+ * @return the mapped LDAP attribute name, or {@code null} if no mapping exists
+ */
+ public String getLdapUserAttribute(String scimAttribute) {
+ return scimToLdapUser.get(scimAttribute);
+ }
+
+ /**
+ * Resolves a SCIM group attribute path (e.g. {@code "displayName"} or {@code "members.value"})
+ * to the corresponding LDAP attribute name.
+ *
+ * @param scimAttribute the SCIM attribute path
+ * @return the mapped LDAP attribute name, or {@code null} if no mapping exists
+ */
+ public String getLdapGroupAttribute(String scimAttribute) {
+ return scimToLdapGroup.get(scimAttribute);
+ }
+
+ /** Returns the LDAP attribute used as the RDN for user entries (e.g. {@code "uid"}). */
+ public String getUserRdnAttribute() {
+ return userRdnAttribute;
+ }
+
+ /** Returns the LDAP attribute used as the RDN for group entries (e.g. {@code "cn"}). */
+ public String getGroupRdnAttribute() {
+ return groupRdnAttribute;
+ }
+
+ /** Returns the LDAP object classes assigned to user entries. */
+ public List<String> getUserObjectClasses() {
+ return userObjectClasses;
+ }
+
+ /** Returns the LDAP object classes assigned to group entries. */
+ public List<String> getGroupObjectClasses() {
+ return groupObjectClasses;
+ }
+
+ /**
+ * Parses an LDAP GeneralizedTime string (RFC 4517) to a UTC {@link LocalDateTime}.
+ *
+ * @param value the GeneralizedTime string, e.g. {@code "20240115143000Z"}
+ * @return the parsed UTC instant as a {@link LocalDateTime}, or {@code null} if the value
+ * is blank or cannot be parsed
+ */
+ private static LocalDateTime parseLdapTimestamp(String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+ try {
+ return ZonedDateTime.parse(value, GENERALIZED_TIME)
+ .withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
+ } catch (DateTimeParseException e) {
+ LOG.warn("Failed to parse LDAP GeneralizedTime '{}': {}", value, e.getMessage());
+ return null;
+ }
+ }
+
+ private static Dn buildDn(String rdnAttribute, String rdnValue, String baseDn) throws LdapException {
+ return new Dn(new Rdn(rdnAttribute, rdnValue), new Dn(baseDn));
+ }
+
+ private String getStringAttribute(Entry entry, String ldapAttrName) throws LdapInvalidAttributeValueException {
+ if (ldapAttrName == null) {
+ return null;
+ }
+ Attribute attr = entry.get(ldapAttrName);
+ if (attr != null) {
+ return attr.getString();
+ }
+ return null;
+ }
+
+ private List<Email> getMultiValuedAsEmails(Entry entry, String ldapAttrName) throws LdapInvalidAttributeValueException {
+ List<Email> result = new ArrayList<>();
+ if (ldapAttrName == null) {
+ return result;
+ }
+ Attribute attr = entry.get(ldapAttrName);
+ if (attr != null) {
+ boolean first = true;
+ for (Value val : attr) {
+ Email email = new Email();
+ email.setValue(val.getString());
+ if (first) {
+ email.setPrimary(true);
+ first = false;
+ }
+ result.add(email);
+ }
+ }
+ return result;
+ }
+
+ private List<PhoneNumber> getMultiValuedAsPhoneNumbers(Entry entry, String ldapAttrName) throws LdapInvalidAttributeValueException {
+ List<PhoneNumber> result = new ArrayList<>();
+ if (ldapAttrName == null) {
+ return result;
+ }
+ Attribute attr = entry.get(ldapAttrName);
+ if (attr != null) {
+ // Read stored type metadata if available
+ String[] types = null;
+ String typeStr = getStringAttribute(entry, "scimPhoneTypes");
+ if (typeStr != null) {
+ types = typeStr.split(",", -1);
+ }
+
+ boolean first = true;
+ int idx = 0;
+ for (Value val : attr) {
+ try {
+ PhoneNumber phone = new PhoneNumber();
+ phone.setValue(val.getString());
+ if (types != null && idx < types.length && !types[idx].isEmpty()) {
+ phone.setType(types[idx]);
+ }
+ if (first) {
+ phone.setPrimary(true);
+ first = false;
+ }
+ result.add(phone);
+ } catch (Exception e) {
+ LOG.warn("Skipping unparseable phone number: {}", val.getString(), e);
+ }
+ idx++;
+ }
+ }
+ return result;
+ }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/mapping/FilterTranslator.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/mapping/FilterTranslator.java
new file mode 100644
index 00000000..05c453fc
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/mapping/FilterTranslator.java
@@ -0,0 +1,191 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.mapping;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.apache.directory.api.ldap.model.filter.AndNode;
+import org.apache.directory.api.ldap.model.filter.EqualityNode;
+import org.apache.directory.api.ldap.model.filter.ExprNode;
+import org.apache.directory.api.ldap.model.filter.GreaterEqNode;
+import org.apache.directory.api.ldap.model.filter.LessEqNode;
+import org.apache.directory.api.ldap.model.filter.NotNode;
+import org.apache.directory.api.ldap.model.filter.ObjectClassNode;
+import org.apache.directory.api.ldap.model.filter.OrNode;
+import org.apache.directory.api.ldap.model.filter.PresenceNode;
+import org.apache.directory.api.ldap.model.filter.SubstringNode;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.spec.filter.AttributeComparisonExpression;
+import org.apache.directory.scim.spec.filter.AttributePresentExpression;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.FilterExpression;
+import org.apache.directory.scim.spec.filter.FilterExpressionVisitor;
+import org.apache.directory.scim.spec.filter.GroupExpression;
+import org.apache.directory.scim.spec.filter.LogicalExpression;
+import org.apache.directory.scim.spec.filter.LogicalOperator;
+import org.apache.directory.scim.spec.filter.ValuePathExpression;
+
+import java.util.List;
+import java.util.function.Function;
+
+/**
+ * Translates SCIM {@link Filter} expressions into Apache Directory LDAP API
+ * {@link ExprNode} filter trees using the {@link FilterExpressionVisitor} pattern.
+ *
+ * <p>All SCIM comparison operators ({@code eq}, {@code ne}, {@code co}, {@code sw},
+ * {@code ew}, {@code gt}, {@code ge}, {@code lt}, {@code le}, {@code pr}) and logical
+ * operators ({@code and}, {@code or}, {@code not}) are supported. SCIM attribute names
+ * are resolved to their LDAP counterparts through the configurable mapping held by
+ * {@link AttributeMapper}. When no mapping is found the SCIM attribute name is used
+ * as-is, providing a best-effort fallback.</p>
+ *
+ * <p>Translation is performed by a private {@link FilterExpressionVisitor} implementation
+ * ({@code LdapFilterVisitor}) that dispatches each SCIM expression node to the appropriate
+ * LDAP filter construction method via {@link FilterExpression#accept(FilterExpressionVisitor)}.</p>
+ *
+ * <p>Filter values are escaped automatically by the {@link ExprNode} implementations,
+ * eliminating the risk of LDAP filter injection.</p>
+ */
+@ApplicationScoped
+public class FilterTranslator {
+
+ @Inject
+ AttributeMapper attributeMapper;
+
+ @Inject
+ ScimLdapConfig config;
+
+ protected FilterTranslator() {}
+
+ /**
+ * Builds a complete LDAP search filter for user queries by combining the configured
+ * user objectClass with the translated SCIM filter.
+ *
+ * @param filter the SCIM filter to translate, may be {@code null}
+ * @return an {@link ExprNode} combining the objectClass constraint with the SCIM filter
+ */
+ public ExprNode buildUserSearchFilter(Filter filter) {
+ ExprNode scimFilter = translateFilter(filter, attributeMapper::getLdapUserAttribute);
+ ExprNode objectClass = new EqualityNode<>("objectClass", config.getUserObjectClasses().get(0));
+ return new AndNode(objectClass, scimFilter);
+ }
+
+ /**
+ * Builds a complete LDAP search filter for group queries by combining the configured
+ * group objectClass with the translated SCIM filter.
+ *
+ * @param filter the SCIM filter to translate, may be {@code null}
+ * @return an {@link ExprNode} combining the objectClass constraint with the SCIM filter
+ */
+ public ExprNode buildGroupSearchFilter(Filter filter) {
+ ExprNode scimFilter = translateFilter(filter, attributeMapper::getLdapGroupAttribute);
+ ExprNode objectClass = new EqualityNode<>("objectClass", config.getGroupObjectClasses().get(0));
+ return new AndNode(objectClass, scimFilter);
+ }
+
+ private ExprNode translateFilter(Filter filter, Function<String, String> attrResolver) {
+ if (filter == null || filter.getExpression() == null) {
+ return ObjectClassNode.OBJECT_CLASS_NODE;
+ }
+ return filter.getExpression().accept(new LdapFilterVisitor(attrResolver));
+ }
+
+ /**
+ * A {@link FilterExpressionVisitor} that translates each SCIM filter expression node
+ * into the corresponding Apache Directory LDAP API {@link ExprNode}.
+ */
+ private static class LdapFilterVisitor implements FilterExpressionVisitor<ExprNode> {
+
+ private final Function<String, String> attrResolver;
+
+ LdapFilterVisitor(Function<String, String> attrResolver) {
+ this.attrResolver = attrResolver;
+ }
+
+ @Override
+ public ExprNode visit(AttributeComparisonExpression expr) {
+ String ldapAttr = resolveAttribute(expr);
+ String value = expr.getCompareValue() != null ? expr.getCompareValue().toString() : "";
+
+ try {
+ return switch (expr.getOperation()) {
+ case EQ -> new EqualityNode<>(ldapAttr, value);
+ case NE -> new NotNode(new EqualityNode<>(ldapAttr, value));
+ case CO -> new SubstringNode(List.of(value), ldapAttr, null, null);
+ case SW -> new SubstringNode(ldapAttr, value, null);
+ case EW -> new SubstringNode(ldapAttr, null, value);
+ case GT -> new AndNode(new GreaterEqNode<>(ldapAttr, value), new NotNode(new EqualityNode<>(ldapAttr, value)));
+ case GE -> new GreaterEqNode<>(ldapAttr, value);
+ case LT -> new AndNode(new LessEqNode<>(ldapAttr, value), new NotNode(new EqualityNode<>(ldapAttr, value)));
+ case LE -> new LessEqNode<>(ldapAttr, value);
+ case PR -> new PresenceNode(ldapAttr);
+ };
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Failed to build LDAP filter for " + ldapAttr + " " + expr.getOperation(), e);
+ }
+ }
+
+ @Override
+ public ExprNode visit(AttributePresentExpression expr) {
+ String scimAttr = expr.getAttributePath().getAttributeName();
+ String ldapAttr = attrResolver.apply(scimAttr);
+ if (ldapAttr == null) {
+ ldapAttr = scimAttr;
+ }
+ return new PresenceNode(ldapAttr);
+ }
+
+ @Override
+ public ExprNode visit(LogicalExpression expr) {
+ ExprNode left = expr.getLeft().accept(this);
+ ExprNode right = expr.getRight().accept(this);
+ if (expr.getOperator() == LogicalOperator.AND) {
+ return new AndNode(left, right);
+ }
+ return new OrNode(left, right);
+ }
+
+ @Override
+ public ExprNode visit(GroupExpression expr) {
+ ExprNode inner = expr.getFilterExpression().accept(this);
+ if (expr.isNot()) {
+ return new NotNode(inner);
+ }
+ return inner;
+ }
+
+ @Override
+ public ExprNode visit(ValuePathExpression expr) {
+ if (expr.getAttributeExpression() != null) {
+ return expr.getAttributeExpression().accept(this);
+ }
+ throw new UnsupportedOperationException(
+ "ValuePathExpression without an attribute expression cannot be translated to LDAP");
+ }
+
+ private String resolveAttribute(AttributeComparisonExpression expr) {
+ String scimAttr = expr.getAttributePath().getAttributeName();
+ String subAttr = expr.getAttributePath().getSubAttributeName();
+ String scimPath = subAttr != null ? scimAttr + "." + subAttr : scimAttr;
+ String ldapAttr = attrResolver.apply(scimPath);
+ return ldapAttr != null ? ldapAttr : scimAttr;
+ }
+ }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/service/LdapGroupRepository.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/service/LdapGroupRepository.java
new file mode 100644
index 00000000..412ccb1f
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/service/LdapGroupRepository.java
@@ -0,0 +1,394 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.service;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+import org.apache.directory.api.ldap.model.entry.Attribute;
+import org.apache.directory.api.ldap.model.entry.DefaultModification;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.Modification;
+import org.apache.directory.api.ldap.model.entry.ModificationOperation;
+import org.apache.directory.api.ldap.model.exception.LdapInvalidDnException;
+import org.apache.directory.api.ldap.model.name.Dn;
+import org.apache.directory.api.ldap.model.name.Rdn;
+import org.apache.directory.scim.core.repository.BaseRepository;
+import org.apache.directory.scim.core.repository.PatchHandler;
+import org.apache.directory.scim.core.repository.ScimRequestContext;
+import org.apache.directory.scim.ldap.ldap.LdapDao;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.ldap.mapping.AttributeMapper;
+import org.apache.directory.scim.spec.exception.ResourceException;
+import org.apache.directory.scim.spec.exception.ResourceNotFoundException;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.FilterResponse;
+import org.apache.directory.scim.spec.filter.PageRequest;
+import org.apache.directory.scim.spec.resources.GroupMembership;
+import org.apache.directory.scim.spec.resources.ScimGroup;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * SCIMple {@link BaseRepository} implementation for SCIM {@link ScimGroup} resources backed by LDAP.
+ *
+ * <p>This repository stores and retrieves group data from an LDAP directory. It uses the
+ * LDAP {@code entryUUID} operational attribute as the SCIM resource identifier. In addition
+ * to standard CRUD operations, this class handles bidirectional resolution between SCIM
+ * member identifiers ({@code entryUUID} values) and LDAP member distinguished names.</p>
+ *
+ * <p>Attribute mapping between SCIM and LDAP is delegated to {@link AttributeMapper}.</p>
+ *
+ *
+ * @see BaseRepository
+ * @see AttributeMapper
+ */
+@Named
+@ApplicationScoped
+public class LdapGroupRepository extends BaseRepository<ScimGroup> {
+
+ private static final Logger LOG = LoggerFactory.getLogger(LdapGroupRepository.class);
+
+ private final LdapDao ldapDao;
+ private final AttributeMapper attributeMapper;
+ private final ScimLdapConfig properties;
+
+ /**
+ * Constructs a new {@code LdapGroupRepository} with the required CDI-managed dependencies.
+ *
+ * @param ldapDao the data access object for LDAP operations (search, create, modify, delete)
+ * @param attributeMapper maps between SCIM {@link ScimGroup} attributes and LDAP entry attributes
+ * @param properties LDAP configuration properties including base DNs
+ * @param patchHandler applies SCIM PATCH operations to produce an updated resource
+ */
+ @Inject
+ public LdapGroupRepository(LdapDao ldapDao, AttributeMapper attributeMapper,
+ ScimLdapConfig properties, PatchHandler patchHandler) {
+ super(ScimGroup.class, patchHandler);
+ this.ldapDao = ldapDao;
+ this.attributeMapper = attributeMapper;
+ this.properties = properties;
+ }
+
+ /**
+ * Constructs a new {@code LdapGroupRepository} without a {@link PatchHandler}.
+ * Useful for testing when patch behavior is not under test.
+ */
+ LdapGroupRepository(LdapDao ldapDao, AttributeMapper attributeMapper, ScimLdapConfig properties) {
+ super(ScimGroup.class, null);
+ this.ldapDao = ldapDao;
+ this.attributeMapper = attributeMapper;
+ this.properties = properties;
+ }
+
+ protected LdapGroupRepository() {
+ super();
+ this.ldapDao = null;
+ this.attributeMapper = null;
+ this.properties = null;
+ }
+
+ /**
+ * Creates a new group in LDAP and returns the resulting SCIM representation.
+ *
+ * <p>Before creating the LDAP entry, member SCIM identifiers ({@code entryUUID} values)
+ * are resolved to LDAP distinguished names. After creation, the entry is read back to
+ * obtain the server-assigned {@code entryUUID}, and member DNs are resolved back to
+ * SCIM identifiers in the returned resource.</p>
+ *
+ * @param resource the SCIM group to create
+ * @param requestContext the current SCIM request context
+ * @return the created {@link ScimGroup} with the server-assigned {@code id} and resolved members
+ * @throws ResourceException if member resolution or the LDAP operation fails
+ */
+ @Override
+ public ScimGroup create(ScimGroup resource, ScimRequestContext requestContext) throws ResourceException {
+ try {
+ // Resolve member SCIM ids to DNs
+ resolveMemberIds(resource);
+
+ Entry entry = attributeMapper.toEntry(resource, properties.getGroupBaseDn());
+ ldapDao.create(entry);
+
+ Entry created = ldapDao.lookup(entry.getDn().toString());
+ return toScimGroupWithResolvedMembers(created);
+ } catch (ResourceException e) {
+ throw e;
+ } catch (Exception e) {
+ LOG.error("Failed to create group", e);
+ throw new ResourceException(500, "Failed to create group");
+ }
+ }
+
+ /**
+ * Replaces an existing LDAP group with the given SCIM resource.
+ *
+ * <p>Locates the existing entry by searching for the {@code entryUUID} matching the given
+ * {@code id}. Member SCIM identifiers are resolved to LDAP DNs before building the
+ * LDAP modifications. The updated attributes are applied via {@code REPLACE_ATTRIBUTE}
+ * operations, and the modified entry is read back with member DNs resolved to SCIM
+ * identifiers.</p>
+ *
+ * @param id the SCIM resource id (LDAP {@code entryUUID})
+ * @param resource the replacement SCIM group data
+ * @param requestContext the current SCIM request context
+ * @return the updated {@link ScimGroup} as read back from LDAP with resolved members
+ * @throws ResourceNotFoundException if no entry with the given {@code entryUUID} exists
+ * @throws ResourceException if member resolution or the LDAP operation fails
+ */
+ @Override
+ public ScimGroup update(String id, ScimGroup resource, ScimRequestContext requestContext) throws ResourceException {
+ try {
+ Entry existing = findByEntryUuid(id);
+ if (existing == null) {
+ throw new ResourceNotFoundException(id);
+ }
+ String dn = existing.getDn().toString();
+
+ resolveMemberIds(resource);
+
+ // Detect displayName change → LDAP modifyDn before attribute modifications.
+ // LDAP does not support multi-operation transactions (RFC 4511). If the
+ // rename succeeds but the subsequent modify fails, the entry will remain at
+ // the new DN with attributes only partially updated. Re-sending the request
+ // to the new DN will recover the entry to a consistent state.
+ String rdnAttr = attributeMapper.getGroupRdnAttribute();
+ Attribute currentRdnAttr = existing.get(rdnAttr);
+ String existingDisplayName = currentRdnAttr != null ? currentRdnAttr.getString() : null;
+ String newDisplayName = resource.getDisplayName();
+ if (newDisplayName != null && !newDisplayName.equals(existingDisplayName)) {
+ String newRdn = new Rdn(rdnAttr, newDisplayName).toString();
+ ldapDao.rename(dn, newRdn);
+ Dn newDn = new Dn(new Rdn(rdnAttr, newDisplayName), existing.getDn().getParent());
+ dn = newDn.toString();
+ }
+
+ Entry updated = attributeMapper.toEntry(resource, properties.getGroupBaseDn());
+
+ List<Modification> modifications = buildReplaceModifications(updated);
+ if (!modifications.isEmpty()) {
+ ldapDao.modify(dn, modifications.toArray(new Modification[0]));
+ }
+
+ Entry result = ldapDao.lookup(dn);
+ return toScimGroupWithResolvedMembers(result);
+ } catch (ResourceException e) {
+ throw e;
+ } catch (Exception e) {
+ LOG.error("Failed to update group", e);
+ throw new ResourceException(500, "Failed to update group");
+ }
+ }
+
+ /**
+ * Retrieves a single SCIM group by its id.
+ *
+ * <p>Performs an LDAP search under the group base DN for an entry whose {@code entryUUID}
+ * matches the given {@code id}. Member DNs in the resulting entry are resolved to SCIM
+ * identifiers. Returns {@code null} if no matching entry is found.</p>
+ *
+ * @param id the SCIM resource id (LDAP {@code entryUUID})
+ * @param requestContext the current SCIM request context
+ * @return the matching {@link ScimGroup} with resolved members, or {@code null} if not found
+ * @throws ResourceException if the LDAP operation fails
+ */
+ @Override
+ public ScimGroup get(String id, ScimRequestContext requestContext) throws ResourceException {
+ try {
+ Entry entry = findByEntryUuid(id);
+ if (entry == null) {
+ return null;
+ }
+ return toScimGroupWithResolvedMembers(entry);
+ } catch (ResourceException e) {
+ throw e;
+ } catch (Exception e) {
+ LOG.error("Failed to get group", e);
+ throw new ResourceException(500, "Failed to get group");
+ }
+ }
+
+ /**
+ * Deletes a SCIM group from LDAP.
+ *
+ * <p>Resolves the SCIM {@code id} to an LDAP entry via an {@code entryUUID} search,
+ * then deletes the entry by its distinguished name.</p>
+ *
+ * @param id the SCIM resource id (LDAP {@code entryUUID})
+ * @throws ResourceNotFoundException if no entry with the given {@code entryUUID} exists
+ * @throws ResourceException if the LDAP operation fails
+ */
+ @Override
+ public void delete(String id) throws ResourceException {
+ Entry entry = findByEntryUuid(id);
+ if (entry == null) {
+ throw new ResourceNotFoundException(id);
+ }
+ ldapDao.delete(entry.getDn().toString());
+ }
+
+ /**
+ * Searches for SCIM groups matching the given filter.
+ *
+ * <p>Delegates to {@link LdapDao#findGroups(Filter, PageRequest)} which translates the SCIM
+ * filter, performs a paged LDAP search under the group base DN, and applies SCIM pagination.
+ * The returned entries are mapped to {@link ScimGroup} instances with member DNs resolved
+ * to SCIM identifiers.</p>
+ *
+ * @param filter the SCIM filter to apply, or {@code null} for all groups
+ * @param requestContext the current SCIM request context (includes pagination parameters)
+ * @return a {@link FilterResponse} containing the matching groups and total count
+ * @throws ResourceException if the LDAP search or mapping fails
+ */
+ @Override
+ public FilterResponse<ScimGroup> find(Filter filter, ScimRequestContext requestContext) throws ResourceException {
+ try {
+ // LdapDao handles LDAP paged results control and SCIM pagination
+ PageRequest pageRequest = requestContext.getPageRequest().orElse(null);
+ FilterResponse<Entry> ldapResults = ldapDao.findGroups(filter, pageRequest);
+
+ // Map LDAP entries to SCIM groups with resolved members
+ List<ScimGroup> groups = new ArrayList<>();
+ for (Entry entry : ldapResults.getResources()) {
+ try {
+ groups.add(toScimGroupWithResolvedMembers(entry));
+ } catch (Exception e) {
+ LOG.error("Failed to map LDAP entry to ScimGroup: {}", entry.getDn(), e);
+ throw new ResourceException(500, "Failed to map LDAP entry: " + entry.getDn());
+ }
+ }
+
+ return new FilterResponse<>(groups, ldapResults.getTotalResults());
+ } catch (ResourceException e) {
+ throw e;
+ } catch (Exception e) {
+ LOG.error("Failed to search groups", e);
+ throw new ResourceException(500, "Failed to search groups");
+ }
+ }
+
+ private Entry findByEntryUuid(String entryUuid) throws ResourceException {
+ return ldapDao.searchByAttribute(properties.getGroupBaseDn(), "entryUUID", entryUuid);
+ }
+
+ private ScimGroup toScimGroupWithResolvedMembers(Entry entry) throws Exception {
+ ScimGroup group = attributeMapper.toScimGroup(entry);
+
+ // Resolve member DNs to SCIM ids (entryUUIDs).
+ // NOTE: N+1 query pattern — each member DN triggers an individual LDAP lookup.
+ // Production deployments with large groups should consider batch resolution
+ // (e.g., a single search with an OR filter on all member DNs) to reduce round-trips.
+ if (group.getMembers() != null) {
+ List<GroupMembership> resolved = new ArrayList<>();
+ for (GroupMembership member : group.getMembers()) {
+ try {
+ // member.getValue() currently holds the DN
+ Entry memberEntry = ldapDao.lookup(member.getValue());
+ if (memberEntry != null) {
+ String memberUuid = memberEntry.get("entryUUID") != null
+ ? memberEntry.get("entryUUID").getString() : member.getValue();
+ // Prefer mail for display (matches SCIM convention), fall back to cn
+ String display = memberEntry.get("mail") != null
+ ? memberEntry.get("mail").getString()
+ : (memberEntry.get("cn") != null ? memberEntry.get("cn").getString() : null);
+
+ GroupMembership resolvedMember = new GroupMembership();
+ resolvedMember.setValue(memberUuid);
+ if (display != null) {
+ resolvedMember.setDisplay(display);
+ }
+ resolved.add(resolvedMember);
+ }
+ } catch (ResourceNotFoundException e) {
+ LOG.warn("Member DN not found (stale reference), skipping: {}", member.getValue());
+ }
+ }
+ group.setMembers(resolved);
+ }
+
+ return group;
+ }
+
+ private void resolveMemberIds(ScimGroup group) throws ResourceException {
+ if (group.getMembers() == null) {
+ return;
+ }
+ List<GroupMembership> resolved = new ArrayList<>();
+ for (GroupMembership member : group.getMembers()) {
+ String memberId = member.getValue();
+ if (memberId == null) {
+ continue;
+ }
+ // If it looks like a DN, validate it parses as one and is under a known base DN
+ if (memberId.contains("=")) {
+ validateMemberDn(memberId);
+ resolved.add(member);
+ continue;
+ }
+ // Otherwise treat it as an entryUUID and resolve to DN
+ Entry memberEntry = ldapDao.searchByAttribute(properties.getUserBaseDn(), "entryUUID", memberId);
+ if (memberEntry == null) {
+ // Also search in groups
+ memberEntry = ldapDao.searchByAttribute(properties.getGroupBaseDn(), "entryUUID", memberId);
+ }
+ if (memberEntry == null) {
+ throw new ResourceException(400, "Member not found: " + memberId);
+ }
+ GroupMembership resolvedMember = new GroupMembership();
+ resolvedMember.setValue(memberEntry.getDn().toString());
+ if (member.getDisplay() != null) {
+ resolvedMember.setDisplay(member.getDisplay());
+ }
+ resolved.add(resolvedMember);
+ }
+ group.setMembers(resolved);
+ }
+
+ private void validateMemberDn(String memberDn) throws ResourceException {
+ try {
+ Dn parsed = new Dn(memberDn);
+ Dn userDn = new Dn(properties.getUserBaseDn());
+ Dn groupDn = new Dn(properties.getGroupBaseDn());
+ if (!parsed.isDescendantOf(userDn) && !parsed.isDescendantOf(groupDn)) {
+ throw new ResourceException(400,
+ "Member DN is not under a recognized base DN: " + memberDn);
+ }
+ } catch (LdapInvalidDnException e) {
+ throw new ResourceException(400, "Invalid member DN: " + memberDn);
+ }
+ }
+
+ private List<Modification> buildReplaceModifications(Entry updated) {
+ List<Modification> modifications = new ArrayList<>();
+
+ for (Attribute attr : updated) {
+ String attrId = attr.getId();
+ if ("objectClass".equalsIgnoreCase(attrId) || attributeMapper.getGroupRdnAttribute().equalsIgnoreCase(attrId)) {
+ continue;
+ }
+ modifications.add(new DefaultModification(ModificationOperation.REPLACE_ATTRIBUTE, attr));
+ }
+
+ return modifications;
+ }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/service/LdapUserRepository.java b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/service/LdapUserRepository.java
new file mode 100644
index 00000000..efe6011f
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/java/org/apache/directory/scim/ldap/service/LdapUserRepository.java
@@ -0,0 +1,297 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.service;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+import org.apache.directory.api.ldap.model.entry.Attribute;
+import org.apache.directory.api.ldap.model.entry.DefaultModification;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.Modification;
+import org.apache.directory.api.ldap.model.entry.ModificationOperation;
+import org.apache.directory.api.ldap.model.name.Dn;
+import org.apache.directory.api.ldap.model.name.Rdn;
+import org.apache.directory.scim.core.repository.BaseRepository;
+import org.apache.directory.scim.core.repository.PatchHandler;
+import org.apache.directory.scim.core.repository.ScimRequestContext;
+import org.apache.directory.scim.ldap.ldap.LdapDao;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.ldap.mapping.AttributeMapper;
+import org.apache.directory.scim.spec.exception.ResourceException;
+import org.apache.directory.scim.spec.exception.ResourceNotFoundException;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.FilterResponse;
+import org.apache.directory.scim.spec.filter.PageRequest;
+import org.apache.directory.scim.spec.resources.ScimUser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * SCIMple {@link BaseRepository} implementation for SCIM {@link ScimUser} resources backed by LDAP.
+ *
+ * <p>This repository stores and retrieves user data from an LDAP directory. It uses the
+ * LDAP {@code entryUUID} operational attribute as the SCIM resource identifier, ensuring
+ * a stable identity that is independent of the entry's distinguished name.</p>
+ *
+ * <p>Attribute mapping between SCIM and LDAP is delegated to {@link AttributeMapper}.</p>
+ *
+ * @see BaseRepository
+ * @see AttributeMapper
+ */
+@Named
+@ApplicationScoped
+public class LdapUserRepository extends BaseRepository<ScimUser> {
+
+ private static final Logger LOG = LoggerFactory.getLogger(LdapUserRepository.class);
+
+ private final LdapDao ldapDao;
+ private final AttributeMapper attributeMapper;
+ private final ScimLdapConfig properties;
+
+ /**
+ * Constructs a new {@code LdapUserRepository} with the required CDI-managed dependencies.
+ *
+ * @param ldapDao the data access object for LDAP operations (search, create, modify, delete)
+ * @param attributeMapper maps between SCIM {@link ScimUser} attributes and LDAP entry attributes
+ * @param properties LDAP configuration properties including base DNs
+ * @param patchHandler applies SCIM PATCH operations to produce an updated resource
+ */
+ @Inject
+ public LdapUserRepository(LdapDao ldapDao, AttributeMapper attributeMapper,
+ ScimLdapConfig properties, PatchHandler patchHandler) {
+ super(ScimUser.class, patchHandler);
+ this.ldapDao = ldapDao;
+ this.attributeMapper = attributeMapper;
+ this.properties = properties;
+ }
+
+ /**
+ * Constructs a new {@code LdapUserRepository} without a {@link PatchHandler}.
+ * Useful for testing when patch behavior is not under test.
+ */
+ LdapUserRepository(LdapDao ldapDao, AttributeMapper attributeMapper, ScimLdapConfig properties) {
+ super(ScimUser.class, null);
+ this.ldapDao = ldapDao;
+ this.attributeMapper = attributeMapper;
+ this.properties = properties;
+ }
+
+ protected LdapUserRepository() {
+ super();
+ this.ldapDao = null;
+ this.attributeMapper = null;
+ this.properties = null;
+ }
+
+ /**
+ * Creates a new user in LDAP and returns the resulting SCIM representation.
+ *
+ * <p>The SCIM resource is mapped to an LDAP entry and added under the configured user base DN.
+ * After creation, the entry is read back from the directory to obtain the server-assigned
+ * {@code entryUUID}, which becomes the SCIM resource {@code id}.</p>
+ *
+ * @param resource the SCIM user to create
+ * @param requestContext the current SCIM request context
+ * @return the created {@link ScimUser} with the server-assigned {@code id}
+ * @throws ResourceException if the LDAP operation fails
+ */
+ @Override
+ public ScimUser create(ScimUser resource, ScimRequestContext requestContext) throws ResourceException {
+ try {
+ Entry entry = attributeMapper.toEntry(resource, properties.getUserBaseDn());
+ ldapDao.create(entry);
+
+ // Read back to get the entryUUID assigned by the LDAP server
+ Entry created = ldapDao.lookup(entry.getDn().toString());
+ return attributeMapper.toScimUser(created);
+ } catch (ResourceException e) {
+ throw e;
+ } catch (Exception e) {
+ LOG.error("Failed to create user", e);
+ throw new ResourceException(500, "Failed to create user");
+ }
+ }
+
+ /**
+ * Replaces an existing LDAP user with the given SCIM resource.
+ *
+ * <p>Locates the existing entry by searching for the {@code entryUUID} matching the given
+ * {@code id}. The updated SCIM attributes are mapped to LDAP modifications (using
+ * {@code REPLACE_ATTRIBUTE} operations) and applied to the entry. The modified entry is
+ * then read back and returned as a SCIM user.</p>
+ *
+ * @param id the SCIM resource id (LDAP {@code entryUUID})
+ * @param resource the replacement SCIM user data
+ * @param requestContext the current SCIM request context
+ * @return the updated {@link ScimUser} as read back from LDAP
+ * @throws ResourceNotFoundException if no entry with the given {@code entryUUID} exists
+ * @throws ResourceException if the LDAP operation fails
+ */
+ @Override
+ public ScimUser update(String id, ScimUser resource, ScimRequestContext requestContext) throws ResourceException {
+ try {
+ Entry existing = findByEntryUuid(id);
+ if (existing == null) {
+ throw new ResourceNotFoundException(id);
+ }
+ String dn = existing.getDn().toString();
+
+ // Detect userName change → LDAP modifyDn before attribute modifications.
+ // LDAP does not support multi-operation transactions (RFC 4511). If the
+ // rename succeeds but the subsequent modify fails, the entry will remain at
+ // the new DN with attributes only partially updated. Re-sending the request
+ // to the new DN will recover the entry to a consistent state.
+ String rdnAttr = attributeMapper.getUserRdnAttribute();
+ Attribute currentRdnAttr = existing.get(rdnAttr);
+ String existingUserName = currentRdnAttr != null ? currentRdnAttr.getString() : null;
+ String newUserName = resource.getUserName();
+ if (newUserName != null && !newUserName.equals(existingUserName)) {
+ String newRdn = new Rdn(rdnAttr, newUserName).toString();
+ ldapDao.rename(dn, newRdn);
+ // Parent DN is unchanged; only the RDN changes
+ Dn newDn = new Dn(new Rdn(rdnAttr, newUserName), existing.getDn().getParent());
+ dn = newDn.toString();
+ }
+
+ Entry updated = attributeMapper.toEntry(resource, properties.getUserBaseDn());
+
+ List<Modification> modifications = buildReplaceModifications(updated);
+ if (!modifications.isEmpty()) {
+ ldapDao.modify(dn, modifications.toArray(new Modification[0]));
+ }
+
+ Entry result = ldapDao.lookup(dn);
+ return attributeMapper.toScimUser(result);
+ } catch (ResourceException e) {
+ throw e;
+ } catch (Exception e) {
+ LOG.error("Failed to update user", e);
+ throw new ResourceException(500, "Failed to update user");
+ }
+ }
+
+ /**
+ * Retrieves a single SCIM user by its id.
+ *
+ * <p>Performs an LDAP search under the user base DN for an entry whose {@code entryUUID}
+ * matches the given {@code id}. Returns {@code null} if no matching entry is found.</p>
+ *
+ * @param id the SCIM resource id (LDAP {@code entryUUID})
+ * @param requestContext the current SCIM request context
+ * @return the matching {@link ScimUser}, or {@code null} if not found
+ * @throws ResourceException if the LDAP operation fails
+ */
+ @Override
+ public ScimUser get(String id, ScimRequestContext requestContext) throws ResourceException {
+ try {
+ Entry entry = findByEntryUuid(id);
+ if (entry == null) {
+ return null;
+ }
+ return attributeMapper.toScimUser(entry);
+ } catch (ResourceException e) {
+ throw e;
+ } catch (Exception e) {
+ LOG.error("Failed to get user", e);
+ throw new ResourceException(500, "Failed to get user");
+ }
+ }
+
+ /**
+ * Deletes a SCIM user from LDAP.
+ *
+ * <p>Resolves the SCIM {@code id} to an LDAP entry via an {@code entryUUID} search,
+ * then deletes the entry by its distinguished name.</p>
+ *
+ * @param id the SCIM resource id (LDAP {@code entryUUID})
+ * @throws ResourceNotFoundException if no entry with the given {@code entryUUID} exists
+ * @throws ResourceException if the LDAP operation fails
+ */
+ @Override
+ public void delete(String id) throws ResourceException {
+ Entry entry = findByEntryUuid(id);
+ if (entry == null) {
+ throw new ResourceNotFoundException(id);
+ }
+ ldapDao.delete(entry.getDn().toString());
+ }
+
+ /**
+ * Searches for SCIM users matching the given filter.
+ *
+ * <p>Delegates to {@link LdapDao#findUsers(Filter, PageRequest)} which translates the SCIM
+ * filter, performs a paged LDAP search under the user base DN, and applies SCIM pagination.
+ * The returned entries are mapped to {@link ScimUser} instances.</p>
+ *
+ * @param filter the SCIM filter to apply, or {@code null} for all users
+ * @param requestContext the current SCIM request context (includes pagination parameters)
+ * @return a {@link FilterResponse} containing the matching users and total count
+ * @throws ResourceException if the LDAP search or mapping fails
+ */
+ @Override
+ public FilterResponse<ScimUser> find(Filter filter, ScimRequestContext requestContext) throws ResourceException {
+ try {
+ // LdapDao handles LDAP paged results control and SCIM pagination
+ PageRequest pageRequest = requestContext.getPageRequest().orElse(null);
+ FilterResponse<Entry> ldapResults = ldapDao.findUsers(filter, pageRequest);
+
+ // Map LDAP entries to SCIM users
+ List<ScimUser> users = new ArrayList<>();
+ for (Entry entry : ldapResults.getResources()) {
+ try {
+ users.add(attributeMapper.toScimUser(entry));
+ } catch (Exception e) {
+ LOG.error("Failed to map LDAP entry to ScimUser: {}", entry.getDn(), e);
+ throw new ResourceException(500, "Failed to map LDAP entry: " + entry.getDn());
+ }
+ }
+
+ return new FilterResponse<>(users, ldapResults.getTotalResults());
+ } catch (ResourceException e) {
+ throw e;
+ } catch (Exception e) {
+ LOG.error("Failed to search users", e);
+ throw new ResourceException(500, "Failed to search users");
+ }
+ }
+
+ private Entry findByEntryUuid(String entryUuid) throws ResourceException {
+ return ldapDao.searchByAttribute(properties.getUserBaseDn(), "entryUUID", entryUuid);
+ }
+
+ private List<Modification> buildReplaceModifications(Entry updated) {
+ List<Modification> modifications = new ArrayList<>();
+
+ for (Attribute attr : updated) {
+ String attrId = attr.getId();
+ // Skip objectClass and DN-related attributes
+ if ("objectClass".equalsIgnoreCase(attrId) || attributeMapper.getUserRdnAttribute().equalsIgnoreCase(attrId)) {
+ continue;
+ }
+ modifications.add(new DefaultModification(ModificationOperation.REPLACE_ATTRIBUTE, attr));
+ }
+
+ return modifications;
+ }
+}
diff --git a/reference-projects/scim-server-ldap/src/main/resources/beans.xml b/reference-projects/scim-server-ldap/src/main/resources/beans.xml
new file mode 100644
index 00000000..c5985a05
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/resources/beans.xml
@@ -0,0 +1,22 @@
+<!-- Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License. -->
+<beans xmlns="http://xmlns.jcp.org/xml/ns/javaee"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/beans_1_1.xsd"
+ version="4.0" bean-discovery-mode="annotated">
+
+</beans>
diff --git a/reference-projects/scim-server-ldap/src/main/resources/logback.xml b/reference-projects/scim-server-ldap/src/main/resources/logback.xml
new file mode 100644
index 00000000..0731c9a2
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/resources/logback.xml
@@ -0,0 +1,26 @@
+<!-- Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License. -->
+<configuration>
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+ <root level="INFO">
+ <appender-ref ref="STDOUT" />
+ </root>
+</configuration>
diff --git a/reference-projects/scim-server-ldap/src/main/resources/scim-ldap.yml b/reference-projects/scim-server-ldap/src/main/resources/scim-ldap.yml
new file mode 100644
index 00000000..0a09faf1
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/main/resources/scim-ldap.yml
@@ -0,0 +1,79 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+# SCIMple LDAP Server Configuration
+#
+# This file configures the LDAP-backed SCIM server reference implementation.
+# LDAP connection settings can be overridden with system properties:
+# -Dldap.host=myserver -Dldap.port=636 -Dldap.bind.dn=cn=scim,dc=corp ...
+#
+# WARNING: The default values below are for development only.
+# Do NOT use them in production. Override bind credentials and enable TLS.
+
+# LDAP server connection settings
+# Override with system properties: -Dldap.host=..., -Dldap.port=..., etc.
+ldap:
+ embedded: false # Set to true (or -Dldap.embedded=true) to start an in-memory ApacheDS
+ # on an ephemeral port; host/port/bind settings below are for external LDAP only
+ host: localhost # LDAP server hostname
+ port: 389 # Use 636 for LDAPS
+ bindDn: cn=admin,dc=example,dc=com
+ bindPassword: secret # WARNING: development only — use -Dldap.bind.password for production
+ useTls: false # Set to true for production; credentials are sent in plaintext without TLS
+ userBaseDn: ou=users,dc=example,dc=com # Base DN for SCIM User searches
+ groupBaseDn: ou=groups,dc=example,dc=com # Base DN for SCIM Group searches
+
+# SCIM User <-> LDAP inetOrgPerson attribute mapping
+# Maps SCIM User attributes (left) to LDAP attributes (right).
+# The "objectClasses" list defines which LDAP object classes are used when creating entries.
+user:
+ objectClasses: # LDAP object classes applied to new user entries
+ - inetOrgPerson
+ - organizationalPerson
+ - person
+ - extensibleObject # Allows custom SCIM attributes (scimActive, scimPhoneTypes) without schema changes
+ - top
+ rdnAttribute: uid # LDAP attribute used as the RDN (Relative Distinguished Name)
+ attributes:
+ # SCIM attribute path : LDAP attribute name
+ userName: uid
+ "name.givenName": givenName
+ "name.familyName": sn
+ "name.formatted": cn
+ displayName: displayName
+ "emails.value": mail
+ "phoneNumbers.value": telephoneNumber
+ "addresses.streetAddress": street
+ "addresses.locality": l # LDAP "l" = locality (city)
+ "addresses.postalCode": postalCode
+ title: title
+ userType: employeeType
+ password: userPassword # Stored as an LDAP hashed password
+
+# SCIM Group <-> LDAP groupOfNames attribute mapping
+# Maps SCIM Group attributes (left) to LDAP attributes (right).
+group:
+ objectClasses: # LDAP object classes applied to new group entries
+ - groupOfNames
+ - top
+ rdnAttribute: cn # Groups are identified by their common name
+ attributes:
+ # SCIM attribute path : LDAP attribute name
+ displayName: cn
+ "members.value": member # Each member value is a full DN of the user entry
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/LdapTestServer.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/LdapTestServer.java
new file mode 100644
index 00000000..e2e176eb
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/LdapTestServer.java
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+
+ * http://www.apache.org/licenses/LICENSE-2.0
+
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.directory.scim.ldap;
+
+import jakarta.annotation.Priority;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Alternative;
+import jakarta.enterprise.inject.se.SeContainer;
+import jakarta.enterprise.inject.se.SeContainerInitializer;
+import jakarta.ws.rs.SeBootstrap;
+import jakarta.ws.rs.core.UriBuilder;
+import org.apache.directory.api.ldap.model.entry.DefaultEntry;
+import org.apache.directory.api.ldap.model.schema.AttributeType;
+import org.apache.directory.api.ldap.model.schema.SchemaManager;
+import org.apache.directory.scim.compliance.junit.EmbeddedServerExtension;
+import org.apache.directory.scim.ldap.ldap.EmbeddedLdapServer;
+import org.apache.directory.scim.ldap.ldap.LdapConnectionManager;
+import org.apache.directory.scim.ldap.ldap.LdapDao;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.ldap.mapping.AttributeMapper;
+import org.apache.directory.scim.ldap.mapping.FilterTranslator;
+import org.apache.directory.scim.ldap.service.LdapGroupRepository;
+import org.apache.directory.scim.ldap.service.LdapUserRepository;
+
+import java.net.URI;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Compliance test harness that starts an embedded Apache DS LDAP server and a SCIM server
+ * for running the SCIMple compliance test suite. Registered via {@code META-INF/services} SPI
+ * as an {@link EmbeddedServerExtension.ScimTestServer} implementation.
+ *
+ * <p>Extends {@link EmbeddedLdapServer} and overrides {@link #seedSampleData(ScimLdapConfig)}
+ * to register custom schema attributes and seed test-specific entries instead of the default
+ * demo data.</p>
+ */
+public class LdapTestServer extends EmbeddedLdapServer
+ implements EmbeddedServerExtension.ScimTestServer {
+
+ private SeContainer container;
+ private SeBootstrap.Instance server;
+
+ /**
+ * CDI {@link Alternative} that overrides {@link ScimLdapConfig} to provide the dynamic LDAP
+ * port assigned to the embedded test server. All other configuration values are inherited
+ * from the test {@code scim-ldap.yml} resource.
+ */
+ @Alternative
+ @Priority(1)
+ @ApplicationScoped
+ public static class TestScimLdapConfig extends ScimLdapConfig {
+ private static volatile int ldapPort;
+
+ /**
+ * Sets the LDAP port that {@link #getPort()} will return.
+ *
+ * @param port the port the embedded LDAP server is listening on
+ */
+ static void configure(int port) {
+ ldapPort = port;
+ }
+
+ /**
+ * Returns the dynamic LDAP port for the embedded test server.
+ *
+ * @return the port the embedded LDAP server is listening on
+ */
+ @Override
+ public int getPort() { return ldapPort; }
+ }
+
+ /**
+ * Seeds test-specific entries instead of the default sample data.
+ *
+ * <p>Registers custom schema attributes ({@code scimActive}, {@code scimPhoneTypes}) and
+ * adds a test user and group used by the compliance test suite.</p>
+ *
+ * @param config the SCIM-LDAP configuration providing base DNs
+ * @throws Exception if entries cannot be created
+ */
+ @Override
+ protected void seedSampleData(ScimLdapConfig config) throws Exception {
+ SchemaManager schemaManager = getDirectoryService().getSchemaManager();
+
+ // Register custom attribute types used by AttributeMapper
+ registerCustomAttribute(schemaManager, "1.3.6.1.4.1.99999.1.1", "scimActive");
+ registerCustomAttribute(schemaManager, "1.3.6.1.4.1.99999.1.2", "scimPhoneTypes");
+
+ var session = getDirectoryService().getAdminSession();
+ String userBaseDn = config.getUserBaseDn();
+ String groupBaseDn = config.getGroupBaseDn();
+
+ // Add a default test user
+ session.add(new DefaultEntry(schemaManager,
+ "uid=testuser," + userBaseDn,
+ "objectClass: inetOrgPerson",
+ "objectClass: organizationalPerson",
+ "objectClass: person",
+ "objectClass: top",
+ "uid: testuser",
+ "cn: Test McTest",
+ "sn: McTest",
+ "givenName: Test",
+ "displayName: Test McTest",
+ "mail: test@example.com"));
+
+ // Add a default test group
+ session.add(new DefaultEntry(schemaManager,
+ "cn=example-group," + groupBaseDn,
+ "objectClass: groupOfNames",
+ "objectClass: top",
+ "cn: example-group",
+ "member: uid=testuser," + userBaseDn));
+ }
+
+ /**
+ * Starts an embedded Apache DS instance, seeds it with test data (users and groups),
+ * then starts a CDI container and JAX-RS SCIM server on the given port.
+ *
+ * @param port the HTTP port the SCIM server should bind to
+ * @return the base URI of the running SCIM server
+ * @throws Exception if any server fails to start or test data cannot be loaded
+ */
+ @Override
+ public URI start(int port) throws Exception {
+
+ // Start embedded LDAP server with test-specific seed data
+ ScimLdapConfig ldapConfig = new ScimLdapConfig(
+ "127.0.0.1", 0, "uid=admin,ou=system", "secret",
+ "ou=users,dc=example,dc=com", "ou=groups,dc=example,dc=com", false);
+ super.start(ldapConfig);
+
+ // Configure test LDAP properties via CDI alternative (no system properties)
+ TestScimLdapConfig.configure(getPort());
+
+ // Start CDI container and SCIM server
+ // Register CDI beans explicitly instead of using addPackages() — Spring Boot's nested
+ // JAR classloader uses URLs that Weld SE's package scanner cannot enumerate.
+ container = SeContainerInitializer.newInstance()
+ .addBeanClasses(
+ LdapApplication.class,
+ ScimLdapConfig.class,
+ LdapConnectionManager.class,
+ LdapDao.class,
+ AttributeMapper.class,
+ FilterTranslator.class,
+ LdapUserRepository.class,
+ LdapGroupRepository.class,
+ TestScimLdapConfig.class
+ )
+ .initialize();
+
+ LdapApplication app = new LdapApplication();
+ server = SeBootstrap.start(app, SeBootstrap.Configuration.builder().port(port).build())
+ .toCompletableFuture().get(1, TimeUnit.MINUTES);
+
+ server.stopOnShutdown(stopResult -> container.close());
+
+ return UriBuilder.fromUri("http://localhost/").port(port).build();
+ }
+
+ /**
+ * Stops the SCIM server, CDI container, and embedded LDAP server, in that order.
+ * Each component is stopped only if it was previously started.
+ *
+ * @throws Exception if any server fails to stop cleanly
+ */
+ @Override
+ public void shutdown() throws Exception {
+ if (server != null) {
+ server.stop().toCompletableFuture()
+ .get(10, TimeUnit.SECONDS);
+ }
+
+ if (container != null) {
+ container.close();
+ }
+
+ super.stop();
+ }
+
+ private static void registerCustomAttribute(SchemaManager schemaManager, String oid, String name)
+ throws Exception {
+ AttributeType attrType = new AttributeType(oid);
+ attrType.setNames(name);
+ attrType.setSyntaxOid("1.3.6.1.4.1.1466.115.121.1.15"); // Directory String
+ attrType.setEqualityOid("2.5.13.2"); // caseIgnoreMatch
+ attrType.setSingleValued(true);
+ schemaManager.add(attrType);
+ }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/EmbeddedLdapServerTest.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/EmbeddedLdapServerTest.java
new file mode 100644
index 00000000..aa407551
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/EmbeddedLdapServerTest.java
@@ -0,0 +1,78 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.ldap;
+
+import org.apache.directory.ldap.client.api.LdapConnection;
+import org.apache.directory.ldap.client.api.LdapNetworkConnection;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link EmbeddedLdapServer}.
+ */
+class EmbeddedLdapServerTest {
+
+ @Test
+ void startsAndSeedsBaseEntriesAndSampleData() throws Exception {
+ ScimLdapConfig config = new ScimLdapConfig(
+ "127.0.0.1", 389, "uid=admin,ou=system", "secret",
+ "ou=users,dc=example,dc=com", "ou=groups,dc=example,dc=com", false);
+ config.init();
+
+ EmbeddedLdapServer server = new EmbeddedLdapServer();
+ try {
+ server.start(config);
+
+ assertThat(server.getHost()).isEqualTo("127.0.0.1");
+ assertThat(server.getPort()).isGreaterThan(0);
+
+ // Connect and verify base entries and sample data exist
+ try (LdapConnection conn = new LdapNetworkConnection(server.getHost(), server.getPort())) {
+ conn.bind("uid=admin,ou=system", "secret");
+
+ // Base entries
+ assertThat(conn.exists("dc=example,dc=com")).isTrue();
+ assertThat(conn.exists("ou=users,dc=example,dc=com")).isTrue();
+ assertThat(conn.exists("ou=groups,dc=example,dc=com")).isTrue();
+
+ // Sample users
+ assertThat(conn.exists("uid=bjensen,ou=users,dc=example,dc=com")).isTrue();
+ assertThat(conn.exists("uid=jsmith,ou=users,dc=example,dc=com")).isTrue();
+ assertThat(conn.exists("uid=awhite,ou=users,dc=example,dc=com")).isTrue();
+
+ // Sample group
+ assertThat(conn.exists("cn=Engineering,ou=groups,dc=example,dc=com")).isTrue();
+
+ conn.unBind();
+ }
+ } finally {
+ server.stop();
+ }
+ }
+
+ @Test
+ void stopIsIdempotent() {
+ EmbeddedLdapServer server = new EmbeddedLdapServer();
+ // Calling stop without start should not throw
+ server.stop();
+ server.stop();
+ }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/LdapDaoTest.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/LdapDaoTest.java
new file mode 100644
index 00000000..074f8bb9
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/LdapDaoTest.java
@@ -0,0 +1,315 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.ldap;
+
+import org.apache.directory.api.ldap.model.cursor.SearchCursor;
+import org.apache.directory.api.ldap.model.entry.DefaultEntry;
+import org.apache.directory.api.ldap.model.entry.DefaultModification;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.Modification;
+import org.apache.directory.api.ldap.model.entry.ModificationOperation;
+import org.apache.directory.api.ldap.model.exception.LdapEntryAlreadyExistsException;
+import org.apache.directory.api.ldap.model.exception.LdapException;
+import org.apache.directory.api.ldap.model.exception.LdapNoSuchObjectException;
+import org.apache.directory.api.ldap.model.filter.ExprNode;
+import org.apache.directory.api.ldap.model.message.SearchRequest;
+import org.apache.directory.ldap.client.api.LdapConnection;
+import org.apache.directory.scim.ldap.mapping.FilterTranslator;
+import org.apache.directory.scim.spec.exception.ConflictResourceException;
+import org.apache.directory.scim.spec.exception.ResourceException;
+import org.apache.directory.scim.spec.exception.ResourceNotFoundException;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.FilterResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class LdapDaoTest {
+
+ // =========================================================================
+ // CRUD and search methods (mock LdapConnectionManager)
+ // =========================================================================
+
+ @Nested
+ @DisplayName("CRUD operations")
+ class CrudTest {
+
+ LdapConnectionManager connectionManager = mock(LdapConnectionManager.class);
+ FilterTranslator filterTranslator = mock(FilterTranslator.class);
+ ScimLdapConfig config = mock(ScimLdapConfig.class);
+ LdapConnection conn = mock(LdapConnection.class);
+ LdapDao dao;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ dao = new LdapDao();
+ setField(dao, "connectionManager", connectionManager);
+ setField(dao, "filterTranslator", filterTranslator);
+ setField(dao, "config", config);
+
+ when(connectionManager.getConnection()).thenReturn(conn);
+ when(config.getUserBaseDn()).thenReturn("ou=users,dc=example,dc=com");
+ when(config.getGroupBaseDn()).thenReturn("ou=groups,dc=example,dc=com");
+ }
+
+ // ----- create -----
+
+ @Test
+ void createSuccess() throws Exception {
+ Entry entry = new DefaultEntry("uid=test,ou=users,dc=example,dc=com");
+ dao.create(entry);
+ verify(conn).add(entry);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ @Test
+ void createDuplicateThrowsConflict() throws Exception {
+ Entry entry = new DefaultEntry("uid=test,ou=users,dc=example,dc=com");
+ doThrow(new LdapEntryAlreadyExistsException()).when(conn).add(entry);
+ assertThatThrownBy(() -> dao.create(entry))
+ .isInstanceOf(ConflictResourceException.class);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ @Test
+ void createGenericLdapExceptionThrows500() throws Exception {
+ Entry entry = new DefaultEntry("uid=test,ou=users,dc=example,dc=com");
+ doThrow(new LdapException("fail")).when(conn).add(entry);
+ assertThatThrownBy(() -> dao.create(entry))
+ .isInstanceOf(ResourceException.class)
+ .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(500));
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ @Test
+ void createAlwaysReleasesConnection() throws Exception {
+ Entry entry = new DefaultEntry("uid=test,ou=users,dc=example,dc=com");
+ doThrow(new LdapException("fail")).when(conn).add(entry);
+ try { dao.create(entry); } catch (ResourceException ignored) {}
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ // ----- lookup -----
+
+ @Test
+ void lookupSuccess() throws Exception {
+ String dn = "uid=test,ou=users,dc=example,dc=com";
+ Entry expected = new DefaultEntry(dn);
+ when(conn.lookup(dn, "*", "+")).thenReturn(expected);
+ assertThat(dao.lookup(dn)).isSameAs(expected);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ @Test
+ void lookupNullThrowsNotFound() throws Exception {
+ String dn = "uid=missing,ou=users,dc=example,dc=com";
+ when(conn.lookup(dn, "*", "+")).thenReturn(null);
+ assertThatThrownBy(() -> dao.lookup(dn))
+ .isInstanceOf(ResourceNotFoundException.class);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ @Test
+ void lookupNoSuchObjectThrowsNotFound() throws Exception {
+ String dn = "uid=missing,ou=users,dc=example,dc=com";
+ when(conn.lookup(dn, "*", "+")).thenThrow(new LdapNoSuchObjectException());
+ assertThatThrownBy(() -> dao.lookup(dn))
+ .isInstanceOf(ResourceNotFoundException.class);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ // ----- searchByAttribute -----
+
+ @Test
+ void searchByAttributeFound() throws Exception {
+ Entry expected = new DefaultEntry("uid=test,ou=users,dc=example,dc=com");
+
+ SearchCursor cursor = mock(SearchCursor.class);
+ when(conn.search(any(SearchRequest.class))).thenReturn(cursor);
+ when(cursor.next()).thenReturn(true, false);
+ when(cursor.isEntry()).thenReturn(true);
+ when(cursor.getEntry()).thenReturn(expected);
+
+ assertThat(dao.searchByAttribute("ou=users,dc=example,dc=com", "uid", "testuser"))
+ .isSameAs(expected);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ @Test
+ void searchByAttributeNotFound() throws Exception {
+ SearchCursor cursor = mock(SearchCursor.class);
+ when(conn.search(any(SearchRequest.class))).thenReturn(cursor);
+ when(cursor.next()).thenReturn(false);
+
+ assertThat(dao.searchByAttribute("ou=users,dc=example,dc=com", "uid", "nobody"))
+ .isNull();
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ // ----- findUsers -----
+
+ @Test
+ void findUsersReturnsEntries() throws Exception {
+ ExprNode mockFilter = mock(ExprNode.class);
+ when(filterTranslator.buildUserSearchFilter(any())).thenReturn(mockFilter);
+
+ Entry entry1 = new DefaultEntry("uid=alice,ou=users,dc=example,dc=com");
+ Entry entry2 = new DefaultEntry("uid=bob,ou=users,dc=example,dc=com");
+
+ SearchCursor cursor = mock(SearchCursor.class);
+ when(conn.search(any(SearchRequest.class))).thenReturn(cursor);
+ when(cursor.next()).thenReturn(true, true, false);
+ when(cursor.isEntry()).thenReturn(true, true);
+ when(cursor.getEntry()).thenReturn(entry1, entry2);
+ when(cursor.getSearchResultDone()).thenReturn(null);
+
+ FilterResponse<Entry> result = dao.findUsers((Filter) null, null);
+ assertThat(result.getResources()).containsExactly(entry1, entry2);
+ assertThat(result.getTotalResults()).isEqualTo(2);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ @Test
+ void findUsersEmptyReturnsEmptyList() throws Exception {
+ ExprNode mockFilter = mock(ExprNode.class);
+ when(filterTranslator.buildUserSearchFilter(any())).thenReturn(mockFilter);
+
+ SearchCursor cursor = mock(SearchCursor.class);
+ when(conn.search(any(SearchRequest.class))).thenReturn(cursor);
+ when(cursor.next()).thenReturn(false);
+ when(cursor.getSearchResultDone()).thenReturn(null);
+
+ FilterResponse<Entry> result = dao.findUsers((Filter) null, null);
+ assertThat(result.getResources()).isEmpty();
+ assertThat(result.getTotalResults()).isEqualTo(0);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ // ----- findGroups -----
+
+ @Test
+ void findGroupsReturnsEntries() throws Exception {
+ ExprNode mockFilter = mock(ExprNode.class);
+ when(filterTranslator.buildGroupSearchFilter(any())).thenReturn(mockFilter);
+
+ Entry entry1 = new DefaultEntry("cn=admins,ou=groups,dc=example,dc=com");
+
+ SearchCursor cursor = mock(SearchCursor.class);
+ when(conn.search(any(SearchRequest.class))).thenReturn(cursor);
+ when(cursor.next()).thenReturn(true, false);
+ when(cursor.isEntry()).thenReturn(true);
+ when(cursor.getEntry()).thenReturn(entry1);
+ when(cursor.getSearchResultDone()).thenReturn(null);
+
+ FilterResponse<Entry> result = dao.findGroups((Filter) null, null);
+ assertThat(result.getResources()).containsExactly(entry1);
+ assertThat(result.getTotalResults()).isEqualTo(1);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ // ----- modify -----
+
+ @Test
+ void modifySuccess() throws Exception {
+ String dn = "uid=test,ou=users,dc=example,dc=com";
+ Modification mod = new DefaultModification(ModificationOperation.REPLACE_ATTRIBUTE, "cn", "New Name");
+ dao.modify(dn, mod);
+ verify(conn).modify(dn, mod);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ @Test
+ void modifyNoSuchObjectThrowsNotFound() throws Exception {
+ String dn = "uid=missing,ou=users,dc=example,dc=com";
+ Modification mod = new DefaultModification(ModificationOperation.REPLACE_ATTRIBUTE, "cn", "New Name");
+ doThrow(new LdapNoSuchObjectException()).when(conn).modify(dn, mod);
+ assertThatThrownBy(() -> dao.modify(dn, mod))
+ .isInstanceOf(ResourceNotFoundException.class);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ // ----- delete -----
+
+ @Test
+ void deleteSuccess() throws Exception {
+ String dn = "uid=test,ou=users,dc=example,dc=com";
+ dao.delete(dn);
+ verify(conn).delete(dn);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ @Test
+ void deleteNoSuchObjectThrowsNotFound() throws Exception {
+ String dn = "uid=missing,ou=users,dc=example,dc=com";
+ doThrow(new LdapNoSuchObjectException()).when(conn).delete(dn);
+ assertThatThrownBy(() -> dao.delete(dn))
+ .isInstanceOf(ResourceNotFoundException.class);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ // ----- rename -----
+
+ @Test
+ void rename_delegatesToConnectionRename() throws Exception {
+ String dn = "uid=old,ou=users,dc=example,dc=com";
+ String newRdn = "uid=new";
+ dao.rename(dn, newRdn);
+ verify(conn).rename(dn, newRdn, true);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ @Test
+ void rename_entryNotFound_throwsResourceNotFoundException() throws Exception {
+ String dn = "uid=missing,ou=users,dc=example,dc=com";
+ doThrow(new LdapNoSuchObjectException()).when(conn).rename(anyString(), anyString(), anyBoolean());
+ assertThatThrownBy(() -> dao.rename(dn, "uid=new"))
+ .isInstanceOf(ResourceNotFoundException.class);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ @Test
+ void rename_entryAlreadyExists_throwsConflictResourceException() throws Exception {
+ String dn = "uid=old,ou=users,dc=example,dc=com";
+ doThrow(new LdapEntryAlreadyExistsException()).when(conn).rename(anyString(), anyString(), anyBoolean());
+ assertThatThrownBy(() -> dao.rename(dn, "uid=existing"))
+ .isInstanceOf(ConflictResourceException.class);
+ verify(connectionManager).releaseConnection(conn);
+ }
+
+ private void setField(Object target, String fieldName, Object value) throws Exception {
+ Field field = target.getClass().getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(target, value);
+ }
+ }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/ScimLdapConfigTest.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/ScimLdapConfigTest.java
new file mode 100644
index 00000000..5f5a5a53
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/ldap/ScimLdapConfigTest.java
@@ -0,0 +1,200 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.ldap;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@Execution(ExecutionMode.SAME_THREAD)
+class ScimLdapConfigTest {
+
+ private static final List<String> SYSTEM_PROPERTY_KEYS = List.of(
+ "ldap.host", "ldap.port", "ldap.bind.dn", "ldap.bind.password",
+ "ldap.base.dn.users", "ldap.base.dn.groups", "ldap.use.tls"
+ );
+
+ @AfterEach
+ void clearSystemProperties() {
+ SYSTEM_PROPERTY_KEYS.forEach(System::clearProperty);
+ }
+
+ // Helper: create an instance via the protected no-arg constructor and call init()
+ private static ScimLdapConfig createAndInit() throws Exception {
+ Constructor<ScimLdapConfig> ctor = ScimLdapConfig.class.getDeclaredConstructor();
+ ctor.setAccessible(true);
+ ScimLdapConfig config = ctor.newInstance();
+
+ Method init = ScimLdapConfig.class.getDeclaredMethod("init");
+ init.setAccessible(true);
+ init.invoke(config);
+ return config;
+ }
+
+ // =========================================================================
+ // Group 1: Programmatic constructor
+ // =========================================================================
+
+ @Nested
+ @DisplayName("Programmatic constructor")
+ class ProgrammaticConstructorTest {
+
+ private final ScimLdapConfig config = new ScimLdapConfig(
+ "ldap.example.org", 636, "cn=admin,dc=test,dc=com", "p@ss",
+ "ou=people,dc=test,dc=com", "ou=roles,dc=test,dc=com", true
+ );
+
+ @Test
+ void hostIsSet() {
+ assertThat(config.getHost()).isEqualTo("ldap.example.org");
+ }
+
+ @Test
+ void portIsSet() {
+ assertThat(config.getPort()).isEqualTo(636);
+ }
+
+ @Test
+ void bindDnIsSet() {
+ assertThat(config.getBindDn()).isEqualTo("cn=admin,dc=test,dc=com");
+ }
+
+ @Test
+ void bindPasswordIsSet() {
+ assertThat(config.getBindPassword()).isEqualTo("p@ss");
+ }
+
+ @Test
+ void userBaseDnIsSet() {
+ assertThat(config.getUserBaseDn()).isEqualTo("ou=people,dc=test,dc=com");
+ }
+
+ @Test
+ void groupBaseDnIsSet() {
+ assertThat(config.getGroupBaseDn()).isEqualTo("ou=roles,dc=test,dc=com");
+ }
+
+ @Test
+ void useTlsIsSet() {
+ assertThat(config.isUseTls()).isTrue();
+ }
+ }
+
+ // =========================================================================
+ // Group 2: init() loading from test classpath YAML
+ // =========================================================================
+
+ @Nested
+ @DisplayName("init() with test classpath YAML")
+ class InitFromYamlTest {
+
+ @Test
+ void loadsLdapConnectionSettings() throws Exception {
+ ScimLdapConfig config = createAndInit();
+
+ assertThat(config.getHost()).isEqualTo("127.0.0.1");
+ assertThat(config.getPort()).isEqualTo(10389);
+ assertThat(config.getBindDn()).isEqualTo("uid=admin,ou=system");
+ assertThat(config.getBindPassword()).isEqualTo("secret");
+ assertThat(config.getUserBaseDn()).isEqualTo("ou=users,dc=example,dc=com");
+ assertThat(config.getGroupBaseDn()).isEqualTo("ou=groups,dc=example,dc=com");
+ assertThat(config.isUseTls()).isFalse();
+ }
+
+ @Test
+ void loadsUserMapping() throws Exception {
+ ScimLdapConfig config = createAndInit();
+
+ assertThat(config.getUserObjectClasses()).contains("extensibleObject", "inetOrgPerson", "top");
+ assertThat(config.getUserRdnAttribute()).isEqualTo("uid");
+ assertThat(config.getUserAttributes())
+ .containsEntry("userName", "uid")
+ .containsEntry("name.givenName", "givenName")
+ .containsEntry("emails.value", "mail");
+ }
+
+ @Test
+ void loadsGroupMapping() throws Exception {
+ ScimLdapConfig config = createAndInit();
+
+ assertThat(config.getGroupObjectClasses()).containsExactly("groupOfNames", "top");
+ assertThat(config.getGroupRdnAttribute()).isEqualTo("cn");
+ assertThat(config.getGroupAttributes())
+ .containsEntry("displayName", "cn")
+ .containsEntry("members.value", "member");
+ }
+ }
+
+ // =========================================================================
+ // Group 3: System property overrides
+ // =========================================================================
+
+ @Nested
+ @DisplayName("System property overrides")
+ class SystemPropertyOverrideTest {
+
+ @Test
+ void systemPropertyOverridesHost() throws Exception {
+ System.setProperty("ldap.host", "override.example.com");
+ ScimLdapConfig config = createAndInit();
+ assertThat(config.getHost()).isEqualTo("override.example.com");
+ }
+
+ @Test
+ void systemPropertyOverridesPort() throws Exception {
+ System.setProperty("ldap.port", "1636");
+ ScimLdapConfig config = createAndInit();
+ assertThat(config.getPort()).isEqualTo(1636);
+ }
+
+ @Test
+ void systemPropertyOverridesBindDn() throws Exception {
+ System.setProperty("ldap.bind.dn", "cn=override,dc=test,dc=com");
+ ScimLdapConfig config = createAndInit();
+ assertThat(config.getBindDn()).isEqualTo("cn=override,dc=test,dc=com");
+ }
+
+ @Test
+ void systemPropertyOverridesUseTls() throws Exception {
+ System.setProperty("ldap.use.tls", "true");
+ ScimLdapConfig config = createAndInit();
+ assertThat(config.isUseTls()).isTrue();
+ }
+
+ @Test
+ void invalidPortValueThrowsIllegalArgumentException() {
+ System.setProperty("ldap.port", "not-a-number");
+ assertThatThrownBy(ScimLdapConfigTest::createAndInit)
+ .isInstanceOf(InvocationTargetException.class)
+ .hasCauseInstanceOf(IllegalArgumentException.class);
+ }
+ }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/mapping/AttributeMapperTest.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/mapping/AttributeMapperTest.java
new file mode 100644
index 00000000..6ffe705b
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/mapping/AttributeMapperTest.java
@@ -0,0 +1,791 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.mapping;
+
+import org.apache.directory.api.ldap.model.entry.DefaultEntry;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.exception.LdapException;
+import org.apache.directory.api.ldap.model.exception.LdapInvalidAttributeValueException;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.spec.resources.Address;
+import org.apache.directory.scim.spec.resources.Email;
+import org.apache.directory.scim.spec.resources.GroupMembership;
+import org.apache.directory.scim.spec.resources.Name;
+import org.apache.directory.scim.spec.resources.PhoneNumber;
+import org.apache.directory.scim.spec.resources.ScimGroup;
+import org.apache.directory.scim.spec.resources.ScimUser;
+import org.apache.directory.scim.spec.schema.Meta;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class AttributeMapperTest {
+
+ static final String USER_BASE_DN = "ou=users,dc=example,dc=com";
+ static final String GROUP_BASE_DN = "ou=groups,dc=example,dc=com";
+
+ AttributeMapper mapper;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ ScimLdapConfig config = mock(ScimLdapConfig.class);
+
+ when(config.getUserObjectClasses()).thenReturn(
+ List.of("inetOrgPerson", "organizationalPerson", "person", "top"));
+ when(config.getUserRdnAttribute()).thenReturn("uid");
+ when(config.getUserAttributes()).thenReturn(defaultUserAttributes());
+
+ when(config.getGroupObjectClasses()).thenReturn(List.of("groupOfNames", "top"));
+ when(config.getGroupRdnAttribute()).thenReturn("cn");
+ when(config.getGroupAttributes()).thenReturn(defaultGroupAttributes());
+
+ mapper = AttributeMapper.class.getDeclaredConstructor().newInstance();
+
+ Field propertiesField = AttributeMapper.class.getDeclaredField("properties");
+ propertiesField.setAccessible(true);
+ propertiesField.set(mapper, config);
+
+ Method initMethod = AttributeMapper.class.getDeclaredMethod("init");
+ initMethod.setAccessible(true);
+ initMethod.invoke(mapper);
+ }
+
+ // ── toScimUser ──────────────────────────────────────────────────────
+
+ @Test
+ void toScimUser_allFieldsPopulated() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "uid=jdoe,ou=users,dc=example,dc=com",
+ "entryUUID: 550e8400-e29b-41d4-a716-446655440000",
+ "uid: jdoe",
+ "givenName: John",
+ "sn: Doe",
+ "cn: John Doe",
+ "displayName: Johnny Doe",
+ "mail: john@example.com",
+ "telephoneNumber: +1-555-1234",
+ "street: 123 Main St",
+ "l: Springfield",
+ "postalCode: 62701",
+ "title: Engineer",
+ "employeeType: Employee",
+ "scimActive: true"
+ );
+
+ ScimUser user = mapper.toScimUser(entry);
+
+ assertThat(user.getId()).isEqualTo("550e8400-e29b-41d4-a716-446655440000");
+ assertThat(user.getUserName()).isEqualTo("jdoe");
+ assertThat(user.getName()).isNotNull();
+ assertThat(user.getName().getGivenName()).isEqualTo("John");
+ assertThat(user.getName().getFamilyName()).isEqualTo("Doe");
+ assertThat(user.getName().getFormatted()).isEqualTo("John Doe");
+ assertThat(user.getDisplayName()).isEqualTo("Johnny Doe");
+ assertThat(user.getEmails()).hasSize(1);
+ assertThat(user.getEmails().get(0).getValue()).isEqualTo("john@example.com");
+ assertThat(user.getPhoneNumbers()).hasSize(1);
+ assertThat(user.getPhoneNumbers().get(0).getValue()).isEqualTo("+1-555-1234");
+ assertThat(user.getAddresses()).hasSize(1);
+ assertThat(user.getAddresses().get(0).getStreetAddress()).isEqualTo("123 Main St");
+ assertThat(user.getAddresses().get(0).getLocality()).isEqualTo("Springfield");
+ assertThat(user.getAddresses().get(0).getPostalCode()).isEqualTo("62701");
+ assertThat(user.getTitle()).isEqualTo("Engineer");
+ assertThat(user.getUserType()).isEqualTo("Employee");
+ assertThat(user.getActive()).isTrue();
+ }
+
+ @Test
+ void toScimUser_minimalEntry_onlyUidAndEntryUuid() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "uid=jdoe,ou=users,dc=example,dc=com",
+ "entryUUID: abc-123",
+ "uid: jdoe"
+ );
+
+ ScimUser user = mapper.toScimUser(entry);
+
+ assertThat(user.getId()).isEqualTo("abc-123");
+ assertThat(user.getUserName()).isEqualTo("jdoe");
+ assertThat(user.getName()).isNull();
+ assertThat(user.getDisplayName()).isNull();
+ assertThat(user.getEmails()).isNull();
+ assertThat(user.getPhoneNumbers()).isNull();
+ assertThat(user.getAddresses()).isNull();
+ assertThat(user.getTitle()).isNull();
+ assertThat(user.getUserType()).isNull();
+ assertThat(user.getActive()).isTrue();
+ }
+
+ @Test
+ void toScimUser_noEntryUuid_idIsNull() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "uid=jdoe,ou=users,dc=example,dc=com",
+ "uid: jdoe"
+ );
+
+ ScimUser user = mapper.toScimUser(entry);
+
+ assertThat(user.getId()).isNull();
+ assertThat(user.getUserName()).isEqualTo("jdoe");
+ }
+
+ @Test
+ void toScimUser_multipleEmails_firstIsPrimary() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "uid=jdoe,ou=users,dc=example,dc=com",
+ "uid: jdoe",
+ "mail: primary@example.com",
+ "mail: secondary@example.com"
+ );
+
+ ScimUser user = mapper.toScimUser(entry);
+
+ assertThat(user.getEmails()).hasSize(2);
+ assertThat(user.getEmails().get(0).getValue()).isEqualTo("primary@example.com");
+ assertThat(user.getEmails().get(0).getPrimary()).isTrue();
+ assertThat(user.getEmails().get(1).getPrimary()).isFalse();
+ }
+
+ @Test
+ void toScimUser_multiplePhones_firstIsPrimary() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "uid=jdoe,ou=users,dc=example,dc=com",
+ "uid: jdoe",
+ "telephoneNumber: +1-555-0001",
+ "telephoneNumber: +1-555-0002"
+ );
+
+ ScimUser user = mapper.toScimUser(entry);
+
+ assertThat(user.getPhoneNumbers()).hasSize(2);
+ assertThat(user.getPhoneNumbers().get(0).getPrimary()).isTrue();
+ assertThat(user.getPhoneNumbers().get(1).getPrimary()).isFalse();
+ }
+
+ @Test
+ void toScimUser_phoneTypesFromScimPhoneTypes() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "uid=jdoe,ou=users,dc=example,dc=com",
+ "uid: jdoe",
+ "telephoneNumber: +1-555-0001",
+ "telephoneNumber: +1-555-0002",
+ "scimPhoneTypes: work,mobile"
+ );
+
+ ScimUser user = mapper.toScimUser(entry);
+
+ assertThat(user.getPhoneNumbers()).hasSize(2);
+ assertThat(user.getPhoneNumbers().get(0).getType()).isEqualTo("work");
+ assertThat(user.getPhoneNumbers().get(1).getType()).isEqualTo("mobile");
+ }
+
+ @Test
+ void toScimUser_noScimActive_defaultsToTrue() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "uid=jdoe,ou=users,dc=example,dc=com",
+ "uid: jdoe"
+ );
+
+ ScimUser user = mapper.toScimUser(entry);
+
+ assertThat(user.getActive()).isTrue();
+ }
+
+ @Test
+ void toScimUser_scimActiveFalse_activeIsFalse() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "uid=jdoe,ou=users,dc=example,dc=com",
+ "uid: jdoe",
+ "scimActive: false"
+ );
+
+ ScimUser user = mapper.toScimUser(entry);
+
+ assertThat(user.getActive()).isFalse();
+ }
+
+ @Test
+ void toScimUser_withBothTimestamps_populatesMetaVersionAndDates() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "uid=jdoe,ou=users,dc=example,dc=com",
+ "uid: jdoe",
+ "createTimestamp: 20240101120000Z",
+ "modifyTimestamp: 20240115143000Z"
+ );
+
+ ScimUser user = mapper.toScimUser(entry);
+
+ Meta meta = user.getMeta();
+ assertThat(meta).isNotNull();
+ assertThat(meta.getResourceType()).isEqualTo("User");
+ assertThat(meta.getVersion()).isEqualTo("W/\"20240115143000Z\"");
+ assertThat(meta.getCreated()).isNotNull();
+ assertThat(meta.getLastModified()).isNotNull();
+ }
+
+ @Test
+ void toScimUser_onlyCreateTimestamp_versionUsesCreateTimestamp() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "uid=jdoe,ou=users,dc=example,dc=com",
+ "uid: jdoe",
+ "createTimestamp: 20240101120000Z"
+ );
+
+ ScimUser user = mapper.toScimUser(entry);
+
+ Meta meta = user.getMeta();
+ assertThat(meta).isNotNull();
+ assertThat(meta.getVersion()).isEqualTo("W/\"20240101120000Z\"");
+ assertThat(meta.getCreated()).isNotNull();
+ assertThat(meta.getLastModified()).isNull();
+ }
+
+ @Test
+ void toScimUser_noTimestamps_metaHasResourceTypeOnly() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "uid=jdoe,ou=users,dc=example,dc=com",
+ "uid: jdoe"
+ );
+
+ ScimUser user = mapper.toScimUser(entry);
+
+ Meta meta = user.getMeta();
+ assertThat(meta).isNotNull();
+ assertThat(meta.getResourceType()).isEqualTo("User");
+ assertThat(meta.getVersion()).isNull();
+ assertThat(meta.getCreated()).isNull();
+ assertThat(meta.getLastModified()).isNull();
+ }
+
+ // ── toEntry(ScimUser, baseDn) ───────────────────────────────────────
+
+ @Test
+ void toEntryUser_constructsDnCorrectly() throws LdapException {
+ ScimUser user = new ScimUser();
+ user.setUserName("jdoe");
+
+ Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+ assertThat(entry.getDn().toString()).isEqualTo("uid=jdoe,ou=users,dc=example,dc=com");
+ }
+
+ @Test
+ void toEntryUser_setsObjectClasses() throws LdapException {
+ ScimUser user = new ScimUser();
+ user.setUserName("jdoe");
+
+ Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+ assertThat(entry.get("objectClass")).isNotNull();
+ assertThat(entry.get("objectClass").size()).isEqualTo(4);
+ }
+
+ @Test
+ void toEntryUser_setsAllMappedAttributes() throws Exception {
+ ScimUser user = new ScimUser();
+ user.setUserName("jdoe");
+ user.setDisplayName("John Doe");
+ user.setName(new Name());
+ user.getName().setGivenName("John");
+ user.getName().setFamilyName("Doe");
+ user.getName().setFormatted("John Doe");
+ user.setTitle("Engineer");
+ user.setUserType("Employee");
+ user.setActive(false);
+
+ Email email = new Email();
+ email.setValue("john@example.com");
+ user.setEmails(List.of(email));
+
+ PhoneNumber phone = new PhoneNumber();
+ phone.setValue("555-1234");
+ phone.setType("work");
+ user.setPhoneNumbers(List.of(phone));
+
+ Address address = new Address();
+ address.setStreetAddress("123 Main St");
+ address.setLocality("Springfield");
+ address.setPostalCode("62701");
+ user.setAddresses(List.of(address));
+
+ Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+ assertThat(entry.get("uid").getString()).isEqualTo("jdoe");
+ assertThat(entry.get("givenName").getString()).isEqualTo("John");
+ assertThat(entry.get("sn").getString()).isEqualTo("Doe");
+ assertThat(entry.get("cn").getString()).isEqualTo("John Doe");
+ assertThat(entry.get("displayName").getString()).isEqualTo("John Doe");
+ assertThat(entry.get("mail").getString()).isEqualTo("john@example.com");
+ assertThat(entry.get("telephoneNumber").getString()).isEqualTo("555-1234");
+ assertThat(entry.get("street").getString()).isEqualTo("123 Main St");
+ assertThat(entry.get("l").getString()).isEqualTo("Springfield");
+ assertThat(entry.get("postalCode").getString()).isEqualTo("62701");
+ assertThat(entry.get("title").getString()).isEqualTo("Engineer");
+ assertThat(entry.get("employeeType").getString()).isEqualTo("Employee");
+ assertThat(entry.get("scimActive").getString()).isEqualTo("false");
+ }
+
+ @Test
+ void toEntryUser_defaultsSnToUserName_whenFamilyNameMissing() throws LdapException {
+ ScimUser user = new ScimUser();
+ user.setUserName("jdoe");
+
+ Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+ assertThat(entry.get("sn").getString()).isEqualTo("jdoe");
+ }
+
+ @Test
+ void toEntryUser_defaultsCnToDisplayName_thenUserName() throws LdapException {
+ // When displayName is set, cn should be the displayName
+ ScimUser userWithDisplay = new ScimUser();
+ userWithDisplay.setUserName("jdoe");
+ userWithDisplay.setDisplayName("John Doe");
+
+ Entry entryWithDisplay = mapper.toEntry(userWithDisplay, USER_BASE_DN);
+ // cn is set as the name.formatted mapping AND as the defaulted cn (displayName takes precedence)
+ assertThat(entryWithDisplay.get("cn").getString()).isEqualTo("John Doe");
+
+ // When displayName is null, cn should default to userName
+ ScimUser userNoDisplay = new ScimUser();
+ userNoDisplay.setUserName("jdoe");
+
+ Entry entryNoDisplay = mapper.toEntry(userNoDisplay, USER_BASE_DN);
+ assertThat(entryNoDisplay.get("cn").getString()).isEqualTo("jdoe");
+ }
+
+ @Test
+ void toEntryUser_nullUserName_throwsIllegalArgument() {
+ ScimUser user = new ScimUser();
+ user.setUserName(null);
+
+ assertThatThrownBy(() -> mapper.toEntry(user, USER_BASE_DN))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("userName is required");
+ }
+
+ @Test
+ void toEntryUser_blankUserName_throwsIllegalArgument() {
+ ScimUser user = new ScimUser();
+ user.setUserName(" ");
+
+ assertThatThrownBy(() -> mapper.toEntry(user, USER_BASE_DN))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("userName is required");
+ }
+
+ // ── toScimGroup ─────────────────────────────────────────────────────
+
+ @Test
+ void toScimGroup_allFieldsPopulated() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "cn=admins,ou=groups,dc=example,dc=com",
+ "entryUUID: group-uuid-1",
+ "cn: Admins",
+ "member: uid=jdoe,ou=users,dc=example,dc=com",
+ "member: uid=asmith,ou=users,dc=example,dc=com"
+ );
+
+ ScimGroup group = mapper.toScimGroup(entry);
+
+ assertThat(group.getId()).isEqualTo("group-uuid-1");
+ assertThat(group.getDisplayName()).isEqualTo("Admins");
+ assertThat(group.getMembers()).hasSize(2);
+ assertThat(group.getMembers().get(0).getValue())
+ .isEqualTo("uid=jdoe,ou=users,dc=example,dc=com");
+ assertThat(group.getMembers().get(1).getValue())
+ .isEqualTo("uid=asmith,ou=users,dc=example,dc=com");
+ }
+
+ @Test
+ void toScimGroup_blankMemberPlaceholder_isSkipped() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "cn=empty,ou=groups,dc=example,dc=com",
+ "entryUUID: group-uuid-2",
+ "cn: Empty Group",
+ "member: ",
+ "member: uid=jdoe,ou=users,dc=example,dc=com"
+ );
+
+ ScimGroup group = mapper.toScimGroup(entry);
+
+ assertThat(group.getMembers()).hasSize(1);
+ assertThat(group.getMembers().get(0).getValue())
+ .isEqualTo("uid=jdoe,ou=users,dc=example,dc=com");
+ }
+
+ @Test
+ void toScimGroup_noMembers_memberListIsNull() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "cn=lonely,ou=groups,dc=example,dc=com",
+ "entryUUID: group-uuid-3",
+ "cn: Lonely Group"
+ );
+
+ ScimGroup group = mapper.toScimGroup(entry);
+
+ assertThat(group.getMembers()).isNull();
+ }
+
+ // ── toEntry(ScimGroup, baseDn) ──────────────────────────────────────
+
+ @Test
+ void toEntryGroup_constructsDnCorrectly() throws LdapException {
+ ScimGroup group = new ScimGroup();
+ group.setDisplayName("Admins");
+
+ Entry entry = mapper.toEntry(group, GROUP_BASE_DN);
+
+ assertThat(entry.getDn().toString()).isEqualTo("cn=Admins,ou=groups,dc=example,dc=com");
+ }
+
+ @Test
+ void toEntryGroup_membersSet() throws LdapException {
+ ScimGroup group = new ScimGroup();
+ group.setDisplayName("Admins");
+
+ GroupMembership m1 = new GroupMembership();
+ m1.setValue("uid=jdoe,ou=users,dc=example,dc=com");
+ GroupMembership m2 = new GroupMembership();
+ m2.setValue("uid=asmith,ou=users,dc=example,dc=com");
+ group.setMembers(List.of(m1, m2));
+
+ Entry entry = mapper.toEntry(group, GROUP_BASE_DN);
+
+ assertThat(entry.get("member")).isNotNull();
+ assertThat(entry.get("member").size()).isEqualTo(2);
+ }
+
+ @Test
+ void toEntryGroup_noMembers_emptyPlaceholderAdded() throws LdapException {
+ ScimGroup group = new ScimGroup();
+ group.setDisplayName("Empty");
+
+ Entry entry = mapper.toEntry(group, GROUP_BASE_DN);
+
+ assertThat(entry.get("member")).isNotNull();
+ assertThat(entry.get("member").getString()).isEmpty();
+ }
+
+ @Test
+ void toEntryGroup_nullDisplayName_throwsIllegalArgument() {
+ ScimGroup group = new ScimGroup();
+ group.setDisplayName(null);
+
+ assertThatThrownBy(() -> mapper.toEntry(group, GROUP_BASE_DN))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("displayName is required");
+ }
+
+ @Test
+ void toEntryGroup_blankDisplayName_throwsIllegalArgument() {
+ ScimGroup group = new ScimGroup();
+ group.setDisplayName(" ");
+
+ assertThatThrownBy(() -> mapper.toEntry(group, GROUP_BASE_DN))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("displayName is required");
+ }
+
+ // ── getLdapUserAttribute ────────────────────────────────────────────
+
+ @Test
+ void getLdapUserAttribute_mappedAttribute_returnsLdapName() {
+ assertThat(mapper.getLdapUserAttribute("userName")).isEqualTo("uid");
+ }
+
+ @Test
+ void getLdapUserAttribute_subAttribute_returnsLdapName() {
+ assertThat(mapper.getLdapUserAttribute("name.givenName")).isEqualTo("givenName");
+ }
+
+ @Test
+ void getLdapUserAttribute_unmappedAttribute_returnsNull() {
+ assertThat(mapper.getLdapUserAttribute("nonExistent")).isNull();
+ }
+
+ // ── getLdapGroupAttribute ───────────────────────────────────────────
+
+ @Test
+ void getLdapGroupAttribute_mappedAttribute_returnsLdapName() {
+ assertThat(mapper.getLdapGroupAttribute("displayName")).isEqualTo("cn");
+ }
+
+ @Test
+ void getLdapGroupAttribute_membersValue_returnsMember() {
+ assertThat(mapper.getLdapGroupAttribute("members.value")).isEqualTo("member");
+ }
+
+ @Test
+ void getLdapGroupAttribute_unmappedAttribute_returnsNull() {
+ assertThat(mapper.getLdapGroupAttribute("nonExistent")).isNull();
+ }
+
+ // ── Getters ─────────────────────────────────────────────────────────
+
+ @Test
+ void getUserRdnAttribute_returnsUid() {
+ assertThat(mapper.getUserRdnAttribute()).isEqualTo("uid");
+ }
+
+ @Test
+ void getGroupRdnAttribute_returnsCn() {
+ assertThat(mapper.getGroupRdnAttribute()).isEqualTo("cn");
+ }
+
+ @Test
+ void getUserObjectClasses_returnsConfiguredList() {
+ assertThat(mapper.getUserObjectClasses())
+ .containsExactly("inetOrgPerson", "organizationalPerson", "person", "top");
+ }
+
+ @Test
+ void getGroupObjectClasses_returnsConfiguredList() {
+ assertThat(mapper.getGroupObjectClasses())
+ .containsExactly("groupOfNames", "top");
+ }
+
+ // ── Additional edge cases ───────────────────────────────────────────
+
+ @Test
+ void toScimUser_partialName_onlyGivenName() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "uid=jdoe,ou=users,dc=example,dc=com",
+ "uid: jdoe",
+ "givenName: John"
+ );
+
+ ScimUser user = mapper.toScimUser(entry);
+
+ assertThat(user.getName()).isNotNull();
+ assertThat(user.getName().getGivenName()).isEqualTo("John");
+ assertThat(user.getName().getFamilyName()).isNull();
+ assertThat(user.getName().getFormatted()).isNull();
+ }
+
+ @Test
+ void toScimUser_partialAddress_onlyStreet() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "uid=jdoe,ou=users,dc=example,dc=com",
+ "uid: jdoe",
+ "street: 456 Oak Ave"
+ );
+
+ ScimUser user = mapper.toScimUser(entry);
+
+ assertThat(user.getAddresses()).hasSize(1);
+ assertThat(user.getAddresses().get(0).getStreetAddress()).isEqualTo("456 Oak Ave");
+ assertThat(user.getAddresses().get(0).getLocality()).isNull();
+ assertThat(user.getAddresses().get(0).getPostalCode()).isNull();
+ }
+
+ @Test
+ void toEntryUser_setsPhoneTypeMetadata() throws Exception {
+ ScimUser user = new ScimUser();
+ user.setUserName("jdoe");
+
+ PhoneNumber workPhone = new PhoneNumber();
+ workPhone.setValue("555-0001");
+ workPhone.setType("work");
+
+ PhoneNumber mobilePhone = new PhoneNumber();
+ mobilePhone.setValue("555-0002");
+ mobilePhone.setType("mobile");
+
+ user.setPhoneNumbers(List.of(workPhone, mobilePhone));
+
+ Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+ assertThat(entry.get("scimPhoneTypes").getString()).isEqualTo("work,mobile");
+ }
+
+ @Test
+ void toEntryUser_setsPassword() throws LdapException {
+ ScimUser user = new ScimUser();
+ user.setUserName("jdoe");
+ user.setPassword("s3cret");
+
+ Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+ assertThat(entry.get("userPassword").getString()).isEqualTo("s3cret");
+ }
+
+ @Test
+ void toEntryGroup_setsObjectClasses() throws LdapException {
+ ScimGroup group = new ScimGroup();
+ group.setDisplayName("Developers");
+
+ Entry entry = mapper.toEntry(group, GROUP_BASE_DN);
+
+ assertThat(entry.get("objectClass")).isNotNull();
+ assertThat(entry.get("objectClass").size()).isEqualTo(2);
+ }
+
+ @Test
+ void toEntryGroup_setsDisplayNameAttribute() throws LdapException {
+ ScimGroup group = new ScimGroup();
+ group.setDisplayName("Developers");
+
+ Entry entry = mapper.toEntry(group, GROUP_BASE_DN);
+
+ assertThat(entry.get("cn").getString()).isEqualTo("Developers");
+ }
+
+ @Test
+ void toScimGroup_noEntryUuid_idIsNull() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "cn=team,ou=groups,dc=example,dc=com",
+ "cn: Team"
+ );
+
+ ScimGroup group = mapper.toScimGroup(entry);
+
+ assertThat(group.getId()).isNull();
+ assertThat(group.getDisplayName()).isEqualTo("Team");
+ }
+
+ @Test
+ void toScimGroup_withBothTimestamps_populatesMetaVersionAndDates() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "cn=admins,ou=groups,dc=example,dc=com",
+ "cn: Admins",
+ "createTimestamp: 20240101120000Z",
+ "modifyTimestamp: 20240115143000Z"
+ );
+
+ ScimGroup group = mapper.toScimGroup(entry);
+
+ Meta meta = group.getMeta();
+ assertThat(meta).isNotNull();
+ assertThat(meta.getResourceType()).isEqualTo("Group");
+ assertThat(meta.getVersion()).isEqualTo("W/\"20240115143000Z\"");
+ assertThat(meta.getCreated()).isNotNull();
+ assertThat(meta.getLastModified()).isNotNull();
+ }
+
+ @Test
+ void toScimGroup_onlyCreateTimestamp_versionUsesCreateTimestamp() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "cn=admins,ou=groups,dc=example,dc=com",
+ "cn: Admins",
+ "createTimestamp: 20240101120000Z"
+ );
+
+ ScimGroup group = mapper.toScimGroup(entry);
+
+ Meta meta = group.getMeta();
+ assertThat(meta).isNotNull();
+ assertThat(meta.getVersion()).isEqualTo("W/\"20240101120000Z\"");
+ assertThat(meta.getCreated()).isNotNull();
+ assertThat(meta.getLastModified()).isNull();
+ }
+
+ @Test
+ void toScimGroup_noTimestamps_metaHasResourceTypeOnly() throws LdapInvalidAttributeValueException, LdapException {
+ Entry entry = new DefaultEntry(
+ "cn=admins,ou=groups,dc=example,dc=com",
+ "cn: Admins"
+ );
+
+ ScimGroup group = mapper.toScimGroup(entry);
+
+ Meta meta = group.getMeta();
+ assertThat(meta).isNotNull();
+ assertThat(meta.getResourceType()).isEqualTo("Group");
+ assertThat(meta.getVersion()).isNull();
+ assertThat(meta.getCreated()).isNull();
+ assertThat(meta.getLastModified()).isNull();
+ }
+
+ @Test
+ void toEntryUser_activeFlagStoredAsCustomAttribute() throws LdapException {
+ ScimUser user = new ScimUser();
+ user.setUserName("jdoe");
+ user.setActive(true);
+
+ Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+ assertThat(entry.get("scimActive").getString()).isEqualTo("true");
+ }
+
+ @Test
+ void toEntryUser_familyNameSet_snUsesFamilyName() throws LdapException {
+ ScimUser user = new ScimUser();
+ user.setUserName("jdoe");
+ Name name = new Name();
+ name.setFamilyName("Doe");
+ user.setName(name);
+
+ Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+ assertThat(entry.get("sn").getString()).isEqualTo("Doe");
+ }
+
+ @Test
+ void toEntryUser_formattedNameSet_cnUsesFormatted() throws LdapException {
+ ScimUser user = new ScimUser();
+ user.setUserName("jdoe");
+ Name name = new Name();
+ name.setFormatted("John Doe");
+ user.setName(name);
+
+ Entry entry = mapper.toEntry(user, USER_BASE_DN);
+
+ // cn is set from the name.formatted mapping (which maps to "cn")
+ assertThat(entry.get("cn").getString()).isEqualTo("John Doe");
+ }
+
+ // ── Helpers ─────────────────────────────────────────────────────────
+
+ private static Map<String, String> defaultUserAttributes() {
+ Map<String, String> attrs = new LinkedHashMap<>();
+ attrs.put("userName", "uid");
+ attrs.put("name.givenName", "givenName");
+ attrs.put("name.familyName", "sn");
+ attrs.put("name.formatted", "cn");
+ attrs.put("displayName", "displayName");
+ attrs.put("emails.value", "mail");
+ attrs.put("phoneNumbers.value", "telephoneNumber");
+ attrs.put("addresses.streetAddress", "street");
+ attrs.put("addresses.locality", "l");
+ attrs.put("addresses.postalCode", "postalCode");
+ attrs.put("title", "title");
+ attrs.put("userType", "employeeType");
+ attrs.put("password", "userPassword");
+ return attrs;
+ }
+
+ private static Map<String, String> defaultGroupAttributes() {
+ Map<String, String> attrs = new LinkedHashMap<>();
+ attrs.put("displayName", "cn");
+ attrs.put("members.value", "member");
+ return attrs;
+ }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/mapping/FilterTranslatorTest.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/mapping/FilterTranslatorTest.java
new file mode 100644
index 00000000..feb8f00e
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/mapping/FilterTranslatorTest.java
@@ -0,0 +1,444 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.mapping;
+
+import org.apache.directory.api.ldap.model.filter.AndNode;
+import org.apache.directory.api.ldap.model.filter.EqualityNode;
+import org.apache.directory.api.ldap.model.filter.ExprNode;
+import org.apache.directory.api.ldap.model.filter.GreaterEqNode;
+import org.apache.directory.api.ldap.model.filter.LessEqNode;
+import org.apache.directory.api.ldap.model.filter.NotNode;
+import org.apache.directory.api.ldap.model.filter.ObjectClassNode;
+import org.apache.directory.api.ldap.model.filter.OrNode;
+import org.apache.directory.api.ldap.model.filter.PresenceNode;
+import org.apache.directory.api.ldap.model.filter.SubstringNode;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.spec.filter.AttributeComparisonExpression;
+import org.apache.directory.scim.spec.filter.AttributePresentExpression;
+import org.apache.directory.scim.spec.filter.CompareOperator;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.GroupExpression;
+import org.apache.directory.scim.spec.filter.ValuePathExpression;
+import org.apache.directory.scim.spec.filter.attribute.AttributeReference;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class FilterTranslatorTest {
+
+ AttributeMapper attributeMapper = mock(AttributeMapper.class);
+ ScimLdapConfig config = mock(ScimLdapConfig.class);
+
+ FilterTranslator filterTranslator;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ filterTranslator = FilterTranslator.class.getDeclaredConstructor().newInstance();
+
+ Field mapperField = FilterTranslator.class.getDeclaredField("attributeMapper");
+ mapperField.setAccessible(true);
+ mapperField.set(filterTranslator, attributeMapper);
+
+ Field configField = FilterTranslator.class.getDeclaredField("config");
+ configField.setAccessible(true);
+ configField.set(filterTranslator, config);
+
+ when(config.getUserObjectClasses()).thenReturn(List.of("inetOrgPerson"));
+ when(config.getGroupObjectClasses()).thenReturn(List.of("groupOfNames"));
+ }
+
+ // --- Null/empty filter ---
+
+ @Test
+ void buildUserSearchFilter_nullFilter_returnsAndWithObjectClassNode() {
+ ExprNode result = filterTranslator.buildUserSearchFilter(null);
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ AndNode andNode = (AndNode) result;
+ assertThat(andNode.getChildren()).hasSize(2);
+ assertThat(andNode.getChildren().get(0)).isInstanceOf(EqualityNode.class);
+ assertThat(andNode.getChildren().get(0).toString()).contains("objectClass=inetOrgPerson");
+ assertThat(andNode.getChildren().get(1)).isSameAs(ObjectClassNode.OBJECT_CLASS_NODE);
+ }
+
+ @Test
+ void buildUserSearchFilter_emptyExpression_returnsAndWithObjectClassNode() throws Exception {
+ java.lang.reflect.Constructor<Filter> ctor = Filter.class.getDeclaredConstructor();
+ ctor.setAccessible(true);
+ Filter filter = ctor.newInstance();
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(filter);
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ AndNode andNode = (AndNode) result;
+ assertThat(andNode.getChildren().get(1)).isSameAs(ObjectClassNode.OBJECT_CLASS_NODE);
+ }
+
+ // --- Comparison operators ---
+
+ @Test
+ void buildUserSearchFilter_eqOperator() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName eq \"john\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(EqualityNode.class);
+ assertThat(scimNode.toString()).contains("uid=john");
+ }
+
+ @Test
+ void buildUserSearchFilter_neOperator() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName ne \"john\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(NotNode.class);
+ ExprNode inner = ((NotNode) scimNode).getFirstChild();
+ assertThat(inner).isInstanceOf(EqualityNode.class);
+ assertThat(inner.toString()).contains("uid=john");
+ }
+
+ @Test
+ void buildUserSearchFilter_coOperator() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName co \"oh\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(SubstringNode.class);
+ assertThat(scimNode.toString()).contains("uid=*oh*");
+ }
+
+ @Test
+ void buildUserSearchFilter_swOperator() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName sw \"jo\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(SubstringNode.class);
+ assertThat(scimNode.toString()).contains("uid=jo*");
+ }
+
+ @Test
+ void buildUserSearchFilter_ewOperator() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName ew \"hn\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(SubstringNode.class);
+ assertThat(scimNode.toString()).contains("uid=*hn");
+ }
+
+ @Test
+ void buildUserSearchFilter_gtOperator() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName gt \"john\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(AndNode.class);
+ AndNode gtNode = (AndNode) scimNode;
+ assertThat(gtNode.getChildren().get(0)).isInstanceOf(GreaterEqNode.class);
+ assertThat(gtNode.getChildren().get(1)).isInstanceOf(NotNode.class);
+ assertThat(gtNode.getChildren().get(0).toString()).contains("uid>=john");
+ }
+
+ @Test
+ void buildUserSearchFilter_geOperator() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName ge \"john\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(GreaterEqNode.class);
+ assertThat(scimNode.toString()).contains("uid>=john");
+ }
+
+ @Test
+ void buildUserSearchFilter_ltOperator() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName lt \"john\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(AndNode.class);
+ AndNode ltNode = (AndNode) scimNode;
+ assertThat(ltNode.getChildren().get(0)).isInstanceOf(LessEqNode.class);
+ assertThat(ltNode.getChildren().get(1)).isInstanceOf(NotNode.class);
+ assertThat(ltNode.getChildren().get(0).toString()).contains("uid<=john");
+ }
+
+ @Test
+ void buildUserSearchFilter_leOperator() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName le \"john\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(LessEqNode.class);
+ assertThat(scimNode.toString()).contains("uid<=john");
+ }
+
+ @Test
+ void buildUserSearchFilter_prOperator() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName pr"));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(PresenceNode.class);
+ assertThat(scimNode.toString()).contains("uid=*");
+ }
+
+ // --- AttributePresentExpression ---
+
+ @Test
+ void buildUserSearchFilter_attributePresentExpression() {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+ Filter filter = new Filter(new AttributePresentExpression(new AttributeReference("userName")));
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(filter);
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(PresenceNode.class);
+ assertThat(scimNode.toString()).contains("uid=*");
+ }
+
+ // --- Logical operators ---
+
+ @Test
+ void buildUserSearchFilter_andOperator() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+ when(attributeMapper.getLdapUserAttribute("displayName")).thenReturn("cn");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(
+ new Filter("userName eq \"john\" and displayName eq \"John Doe\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(AndNode.class);
+ AndNode innerAnd = (AndNode) scimNode;
+ assertThat(innerAnd.getChildren()).hasSize(2);
+ assertThat(innerAnd.getChildren().get(0)).isInstanceOf(EqualityNode.class);
+ assertThat(innerAnd.getChildren().get(1)).isInstanceOf(EqualityNode.class);
+ assertThat(scimNode.toString()).contains("uid=john");
+ assertThat(scimNode.toString()).contains("cn=John Doe");
+ }
+
+ @Test
+ void buildUserSearchFilter_orOperator() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+ when(attributeMapper.getLdapUserAttribute("displayName")).thenReturn("cn");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(
+ new Filter("userName eq \"john\" or displayName eq \"John Doe\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(OrNode.class);
+ OrNode orNode = (OrNode) scimNode;
+ assertThat(orNode.getChildren()).hasSize(2);
+ assertThat(scimNode.toString()).contains("uid=john");
+ assertThat(scimNode.toString()).contains("cn=John Doe");
+ }
+
+ // --- GroupExpression ---
+
+ @Test
+ void buildUserSearchFilter_groupExpressionNotTrue() {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+ AttributeComparisonExpression inner = new AttributeComparisonExpression(
+ new AttributeReference("userName"), CompareOperator.EQ, "john");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter(new GroupExpression(true, inner)));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(NotNode.class);
+ assertThat(scimNode.toString()).contains("uid=john");
+ }
+
+ @Test
+ void buildUserSearchFilter_groupExpressionNotFalse() {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+ AttributeComparisonExpression inner = new AttributeComparisonExpression(
+ new AttributeReference("userName"), CompareOperator.EQ, "john");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter(new GroupExpression(false, inner)));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(EqualityNode.class);
+ assertThat(scimNode.toString()).contains("uid=john");
+ }
+
+ // --- ValuePathExpression ---
+
+ @Test
+ void buildUserSearchFilter_valuePathExpressionWithInnerExpression() {
+ when(attributeMapper.getLdapUserAttribute("type")).thenReturn("emailType");
+ AttributeComparisonExpression inner = new AttributeComparisonExpression(
+ new AttributeReference("type"), CompareOperator.EQ, "work");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(
+ new Filter(ValuePathExpression.fromFilterExpression("emails", inner)));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(EqualityNode.class);
+ assertThat(scimNode.toString()).contains("emailType=work");
+ }
+
+ @Test
+ void buildUserSearchFilter_valuePathExpressionWithoutInnerExpression_throwsUnsupported() {
+ Filter filter = new Filter(new ValuePathExpression(new AttributeReference("emails")));
+ assertThatThrownBy(() -> filterTranslator.buildUserSearchFilter(filter))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("ValuePathExpression without an attribute expression");
+ }
+
+ // --- Attribute resolution ---
+
+ @Test
+ void buildUserSearchFilter_unmappedAttribute_fallsBackToScimName() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn(null);
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName eq \"john\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(EqualityNode.class);
+ assertThat(scimNode.toString()).contains("userName=john");
+ }
+
+ @Test
+ void buildUserSearchFilter_subAttribute_resolvesCorrectly() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("name.givenName")).thenReturn("givenName");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("name.givenName eq \"John\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(EqualityNode.class);
+ assertThat(scimNode.toString()).contains("givenName=John");
+ }
+
+ // --- Special characters in values ---
+
+ @Test
+ void buildUserSearchFilter_specialCharactersAreEscaped() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName eq \"jo*hn\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(EqualityNode.class);
+ assertThat(scimNode.toString()).contains("uid=jo\\2Ahn");
+ }
+
+ @Test
+ void buildUserSearchFilter_parenthesesInValueAreEscaped() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName eq \"jo(h)n\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(EqualityNode.class);
+ assertThat(scimNode.toString()).contains("uid=jo\\28h\\29n");
+ }
+
+ @Test
+ void buildUserSearchFilter_backslashInValueIsEscaped() throws Exception {
+ when(attributeMapper.getLdapUserAttribute("userName")).thenReturn("uid");
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(new Filter("userName eq \"jo\\\\hn\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(EqualityNode.class);
+ assertThat(scimNode.toString()).contains("uid=jo\\5C\\5Chn");
+ }
+
+ // --- Group filter uses group attribute mapping ---
+
+ @Test
+ void buildGroupSearchFilter_usesGroupAttributeMapping() throws Exception {
+ when(attributeMapper.getLdapGroupAttribute("displayName")).thenReturn("cn");
+
+ ExprNode result = filterTranslator.buildGroupSearchFilter(new Filter("displayName eq \"Admins\""));
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ AndNode andNode = (AndNode) result;
+ assertThat(andNode.getChildren().get(0)).isInstanceOf(EqualityNode.class);
+ assertThat(andNode.getChildren().get(0).toString()).contains("objectClass=groupOfNames");
+ ExprNode scimNode = andNode.getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(EqualityNode.class);
+ assertThat(scimNode.toString()).contains("cn=Admins");
+ }
+
+ @Test
+ void buildGroupSearchFilter_nullFilter_returnsAndWithObjectClassNode() {
+ ExprNode result = filterTranslator.buildGroupSearchFilter(null);
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ AndNode andNode = (AndNode) result;
+ assertThat(andNode.getChildren()).hasSize(2);
+ assertThat(andNode.getChildren().get(0)).isInstanceOf(EqualityNode.class);
+ assertThat(andNode.getChildren().get(0).toString()).contains("objectClass=groupOfNames");
+ assertThat(andNode.getChildren().get(1)).isSameAs(ObjectClassNode.OBJECT_CLASS_NODE);
+ }
+
+ @Test
+ void buildUserSearchFilter_unmappedPresentAttribute_fallsBackToScimName() {
+ when(attributeMapper.getLdapUserAttribute("unknownAttr")).thenReturn(null);
+ Filter filter = new Filter(new AttributePresentExpression(new AttributeReference("unknownAttr")));
+
+ ExprNode result = filterTranslator.buildUserSearchFilter(filter);
+
+ assertThat(result).isInstanceOf(AndNode.class);
+ ExprNode scimNode = ((AndNode) result).getChildren().get(1);
+ assertThat(scimNode).isInstanceOf(PresenceNode.class);
+ assertThat(scimNode.toString()).contains("unknownAttr=*");
+ }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/service/LdapGroupRepositoryTest.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/service/LdapGroupRepositoryTest.java
new file mode 100644
index 00000000..a303f468
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/service/LdapGroupRepositoryTest.java
@@ -0,0 +1,724 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.service;
+
+import org.apache.directory.api.ldap.model.entry.DefaultEntry;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.Modification;
+import org.apache.directory.scim.core.repository.ScimRequestContext;
+import org.apache.directory.scim.ldap.ldap.LdapDao;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.ldap.mapping.AttributeMapper;
+import org.apache.directory.scim.spec.exception.ResourceException;
+import org.apache.directory.scim.spec.exception.ResourceNotFoundException;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.FilterResponse;
+import org.apache.directory.scim.spec.filter.PageRequest;
+import org.apache.directory.scim.spec.resources.GroupMembership;
+import org.apache.directory.scim.spec.resources.ScimGroup;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class LdapGroupRepositoryTest {
+
+ static final String GROUP_BASE_DN = "ou=groups,dc=example,dc=com";
+ static final String USER_BASE_DN = "ou=users,dc=example,dc=com";
+
+ LdapDao ldapDao = mock(LdapDao.class);
+ AttributeMapper attributeMapper = mock(AttributeMapper.class);
+ ScimLdapConfig config = mock(ScimLdapConfig.class);
+
+ LdapGroupRepository repository;
+ ScimRequestContext requestContext;
+
+ @BeforeEach
+ void setUp() {
+ when(config.getGroupBaseDn()).thenReturn(GROUP_BASE_DN);
+ when(config.getUserBaseDn()).thenReturn(USER_BASE_DN);
+ when(attributeMapper.getGroupRdnAttribute()).thenReturn("cn");
+
+ repository = new LdapGroupRepository(ldapDao, attributeMapper, config);
+ requestContext = ScimRequestContext.empty();
+ }
+
+ // =========================================================================
+ // Helper methods
+ // =========================================================================
+
+ private static Entry memberEntryWithMailAndUuid(String dn, String uuid, String mail) throws Exception {
+ Entry entry = new DefaultEntry(dn);
+ entry.add("entryUUID", uuid);
+ entry.add("mail", mail);
+ return entry;
+ }
+
+ private static Entry memberEntryWithCnAndUuid(String dn, String uuid, String cn) throws Exception {
+ Entry entry = new DefaultEntry(dn);
+ entry.add("entryUUID", uuid);
+ entry.add("cn", cn);
+ return entry;
+ }
+
+ private static ScimGroup scimGroupWithMembers(String displayName, List<GroupMembership> members) {
+ ScimGroup group = new ScimGroup();
+ group.setDisplayName(displayName);
+ group.setMembers(members);
+ return group;
+ }
+
+ private static GroupMembership membership(String value) {
+ GroupMembership m = new GroupMembership();
+ m.setValue(value);
+ return m;
+ }
+
+ private static ScimGroup firstResource(FilterResponse<ScimGroup> response) {
+ return response.getResources().iterator().next();
+ }
+
+ // =========================================================================
+ // find
+ // =========================================================================
+
+ @Nested
+ @DisplayName("find")
+ class FindTest {
+
+ @Test
+ @DisplayName("null filter delegates to findGroups")
+ void nullFilter_delegatesToFindGroups() throws Exception {
+ when(ldapDao.findGroups(any(), any())).thenReturn(new FilterResponse<>(Collections.emptyList(), 0));
+
+ FilterResponse<ScimGroup> response = repository.find(null, requestContext);
+
+ assertThat(response.getResources()).isEmpty();
+ verify(ldapDao).findGroups(any(), any());
+ }
+
+ @Test
+ @DisplayName("with filter delegates to findGroups")
+ void withFilter_delegatesToFindGroups() throws Exception {
+ Filter filter = mock(Filter.class);
+ when(ldapDao.findGroups(any(), any())).thenReturn(new FilterResponse<>(Collections.emptyList(), 0));
+
+ repository.find(filter, requestContext);
+
+ verify(ldapDao).findGroups(any(), any());
+ }
+
+ @Test
+ @DisplayName("returns groups with resolved members")
+ void returnsGroupsWithResolvedMembers() throws Exception {
+ Entry groupEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ groupEntry.add("entryUUID", "group-uuid-1");
+
+ ScimGroup mappedGroup = scimGroupWithMembers("admins",
+ new ArrayList<>(List.of(membership("uid=jdoe,ou=users,dc=example,dc=com"))));
+ mappedGroup.setId("group-uuid-1");
+ when(attributeMapper.toScimGroup(groupEntry)).thenReturn(mappedGroup);
+
+ Entry memberEntry = memberEntryWithMailAndUuid("uid=jdoe,ou=users,dc=example,dc=com", "member-uuid-1", "jdoe@example.com");
+ when(ldapDao.lookup("uid=jdoe,ou=users,dc=example,dc=com")).thenReturn(memberEntry);
+
+ when(ldapDao.findGroups(any(), any())).thenReturn(new FilterResponse<>(List.of(groupEntry), 1));
+
+ FilterResponse<ScimGroup> response = repository.find(null, requestContext);
+
+ assertThat(response.getResources()).hasSize(1);
+ ScimGroup result = firstResource(response);
+ assertThat(result.getMembers()).hasSize(1);
+ assertThat(result.getMembers().get(0).getValue()).isEqualTo("member-uuid-1");
+ assertThat(result.getMembers().get(0).getDisplay()).isEqualTo("jdoe@example.com");
+ }
+
+ @Test
+ @DisplayName("member resolution prefers mail for display")
+ void memberResolutionPrefersMailForDisplay() throws Exception {
+ Entry groupEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ ScimGroup mappedGroup = scimGroupWithMembers("admins",
+ new ArrayList<>(List.of(membership("uid=jdoe,ou=users,dc=example,dc=com"))));
+ when(attributeMapper.toScimGroup(groupEntry)).thenReturn(mappedGroup);
+
+ Entry memberEntry = new DefaultEntry("uid=jdoe,ou=users,dc=example,dc=com");
+ memberEntry.add("entryUUID", "member-uuid-1");
+ memberEntry.add("mail", "jdoe@example.com");
+ memberEntry.add("cn", "John Doe");
+ when(ldapDao.lookup("uid=jdoe,ou=users,dc=example,dc=com")).thenReturn(memberEntry);
+
+ when(ldapDao.findGroups(any(), any())).thenReturn(new FilterResponse<>(List.of(groupEntry), 1));
+
+ FilterResponse<ScimGroup> response = repository.find(null, requestContext);
+ ScimGroup result = firstResource(response);
+ assertThat(result.getMembers().get(0).getDisplay()).isEqualTo("jdoe@example.com");
+ }
+
+ @Test
+ @DisplayName("member resolution falls back to cn when no mail")
+ void memberResolutionFallsBackToCn() throws Exception {
+ Entry groupEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ ScimGroup mappedGroup = scimGroupWithMembers("admins",
+ new ArrayList<>(List.of(membership("uid=jdoe,ou=users,dc=example,dc=com"))));
+ when(attributeMapper.toScimGroup(groupEntry)).thenReturn(mappedGroup);
+
+ Entry memberEntry = memberEntryWithCnAndUuid("uid=jdoe,ou=users,dc=example,dc=com", "member-uuid-1", "John Doe");
+ when(ldapDao.lookup("uid=jdoe,ou=users,dc=example,dc=com")).thenReturn(memberEntry);
+
+ when(ldapDao.findGroups(any(), any())).thenReturn(new FilterResponse<>(List.of(groupEntry), 1));
+
+ FilterResponse<ScimGroup> response = repository.find(null, requestContext);
+ ScimGroup result = firstResource(response);
+ assertThat(result.getMembers().get(0).getDisplay()).isEqualTo("John Doe");
+ }
+
+ @Test
+ @DisplayName("stale member reference is skipped")
+ void staleMemberReference_memberSkipped() throws Exception {
+ Entry groupEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ ScimGroup mappedGroup = scimGroupWithMembers("admins",
+ new ArrayList<>(List.of(membership("uid=deleted,ou=users,dc=example,dc=com"))));
+ when(attributeMapper.toScimGroup(groupEntry)).thenReturn(mappedGroup);
+
+ when(ldapDao.lookup("uid=deleted,ou=users,dc=example,dc=com"))
+ .thenThrow(new ResourceNotFoundException("uid=deleted,ou=users,dc=example,dc=com"));
+
+ when(ldapDao.findGroups(any(), any())).thenReturn(new FilterResponse<>(List.of(groupEntry), 1));
+
+ FilterResponse<ScimGroup> response = repository.find(null, requestContext);
+ ScimGroup result = firstResource(response);
+ assertThat(result.getMembers()).isEmpty();
+ }
+
+ @Test
+ @DisplayName("empty results returns empty FilterResponse")
+ void emptyResults_returnsEmptyFilterResponse() throws Exception {
+ when(ldapDao.findGroups(any(), any())).thenReturn(new FilterResponse<>(Collections.emptyList(), 0));
+
+ FilterResponse<ScimGroup> response = repository.find(null, requestContext);
+
+ assertThat(response.getResources()).isEmpty();
+ assertThat(response.getTotalResults()).isZero();
+ }
+
+ // ----- Pagination tests -----
+
+ @Test
+ @DisplayName("totalResults reflects total matching entries, not page size")
+ void totalResults_reflectsTotalNotPageSize() throws Exception {
+ Entry groupEntry1 = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ Entry groupEntry2 = new DefaultEntry("cn=devs," + GROUP_BASE_DN);
+
+ ScimGroup group1 = new ScimGroup();
+ group1.setDisplayName("admins");
+ ScimGroup group2 = new ScimGroup();
+ group2.setDisplayName("devs");
+
+ when(ldapDao.findGroups(any(), any()))
+ .thenReturn(new FilterResponse<>(List.of(groupEntry1, groupEntry2), 3));
+ when(attributeMapper.toScimGroup(groupEntry1)).thenReturn(group1);
+ when(attributeMapper.toScimGroup(groupEntry2)).thenReturn(group2);
+
+ ScimRequestContext context = new ScimRequestContext()
+ .setPageRequest(new PageRequest().setStartIndex(1).setCount(2));
+
+ FilterResponse<ScimGroup> response = repository.find(null, context);
+
+ assertThat(response.getResources()).containsExactly(group1, group2);
+ assertThat(response.getTotalResults()).isEqualTo(3);
+ }
+
+ @Test
+ @DisplayName("pagination middle page returns correct slice with total count")
+ void paginationMiddlePage_returnsCorrectSlice() throws Exception {
+ Entry groupEntry2 = new DefaultEntry("cn=devs," + GROUP_BASE_DN);
+
+ ScimGroup group2 = new ScimGroup();
+ group2.setDisplayName("devs");
+
+ when(ldapDao.findGroups(any(), any()))
+ .thenReturn(new FilterResponse<>(List.of(groupEntry2), 3));
+ when(attributeMapper.toScimGroup(groupEntry2)).thenReturn(group2);
+
+ ScimRequestContext context = new ScimRequestContext()
+ .setPageRequest(new PageRequest().setStartIndex(2).setCount(1));
+
+ FilterResponse<ScimGroup> response = repository.find(null, context);
+
+ assertThat(response.getResources()).containsExactly(group2);
+ assertThat(response.getTotalResults()).isEqualTo(3);
+ }
+
+ @Test
+ @DisplayName("no pagination returns all with correct totalResults")
+ void noPagination_returnsAllWithCorrectTotalResults() throws Exception {
+ Entry groupEntry1 = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ Entry groupEntry2 = new DefaultEntry("cn=devs," + GROUP_BASE_DN);
+
+ ScimGroup group1 = new ScimGroup();
+ group1.setDisplayName("admins");
+ ScimGroup group2 = new ScimGroup();
+ group2.setDisplayName("devs");
+
+ when(ldapDao.findGroups(any(), any()))
+ .thenReturn(new FilterResponse<>(List.of(groupEntry1, groupEntry2), 2));
+ when(attributeMapper.toScimGroup(groupEntry1)).thenReturn(group1);
+ when(attributeMapper.toScimGroup(groupEntry2)).thenReturn(group2);
+
+ FilterResponse<ScimGroup> response = repository.find(null, requestContext);
+
+ assertThat(response.getResources()).containsExactly(group1, group2);
+ assertThat(response.getTotalResults()).isEqualTo(2);
+ }
+ }
+
+ // =========================================================================
+ // get
+ // =========================================================================
+
+ @Nested
+ @DisplayName("get")
+ class GetTest {
+
+ @Test
+ @DisplayName("found returns ScimGroup with resolved members")
+ void found_returnsScimGroupWithResolvedMembers() throws Exception {
+ Entry groupEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ groupEntry.add("entryUUID", "group-uuid-1");
+
+ when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", "group-uuid-1"))
+ .thenReturn(groupEntry);
+
+ ScimGroup mappedGroup = scimGroupWithMembers("admins",
+ new ArrayList<>(List.of(membership("uid=jdoe,ou=users,dc=example,dc=com"))));
+ mappedGroup.setId("group-uuid-1");
+ when(attributeMapper.toScimGroup(groupEntry)).thenReturn(mappedGroup);
+
+ Entry memberEntry = memberEntryWithMailAndUuid("uid=jdoe,ou=users,dc=example,dc=com", "member-uuid-1", "jdoe@example.com");
+ when(ldapDao.lookup("uid=jdoe,ou=users,dc=example,dc=com")).thenReturn(memberEntry);
+
+ ScimGroup result = repository.get("group-uuid-1", requestContext);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getDisplayName()).isEqualTo("admins");
+ assertThat(result.getMembers()).hasSize(1);
+ assertThat(result.getMembers().get(0).getValue()).isEqualTo("member-uuid-1");
+ assertThat(result.getMembers().get(0).getDisplay()).isEqualTo("jdoe@example.com");
+ }
+
+ @Test
+ @DisplayName("not found returns null")
+ void notFound_returnsNull() throws Exception {
+ when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", "missing-uuid"))
+ .thenReturn(null);
+
+ ScimGroup result = repository.get("missing-uuid", requestContext);
+
+ assertThat(result).isNull();
+ }
+ }
+
+ // =========================================================================
+ // create
+ // =========================================================================
+
+ @Nested
+ @DisplayName("create")
+ class CreateTest {
+
+ @Test
+ @DisplayName("success with member UUID resolution")
+ void successWithMemberUuidResolution() throws Exception {
+ ScimGroup resource = new ScimGroup();
+ resource.setDisplayName("admins");
+ GroupMembership member = membership("member-uuid-1");
+ resource.setMembers(new ArrayList<>(List.of(member)));
+
+ // Resolve member UUID to DN
+ Entry memberEntry = new DefaultEntry("uid=jdoe,ou=users,dc=example,dc=com");
+ memberEntry.add("entryUUID", "member-uuid-1");
+ when(ldapDao.searchByAttribute(USER_BASE_DN, "entryUUID", "member-uuid-1"))
+ .thenReturn(memberEntry);
+
+ Entry ldapEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(ldapEntry);
+
+ Entry createdEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ createdEntry.add("entryUUID", "new-group-uuid");
+ when(ldapDao.lookup("cn=admins," + GROUP_BASE_DN)).thenReturn(createdEntry);
+
+ ScimGroup mappedResult = new ScimGroup();
+ mappedResult.setId("new-group-uuid");
+ mappedResult.setDisplayName("admins");
+ when(attributeMapper.toScimGroup(createdEntry)).thenReturn(mappedResult);
+
+ ScimGroup result = repository.create(resource, requestContext);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getId()).isEqualTo("new-group-uuid");
+ verify(ldapDao).create(ldapEntry);
+ verify(ldapDao).lookup("cn=admins," + GROUP_BASE_DN);
+ }
+
+ @Test
+ @DisplayName("member not found throws ResourceException 400")
+ void memberNotFound_throwsResourceException400() throws Exception {
+ ScimGroup resource = new ScimGroup();
+ resource.setDisplayName("admins");
+ resource.setMembers(new ArrayList<>(List.of(membership("nonexistent-uuid"))));
+
+ when(ldapDao.searchByAttribute(USER_BASE_DN, "entryUUID", "nonexistent-uuid"))
+ .thenReturn(null);
+ when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", "nonexistent-uuid"))
+ .thenReturn(null);
+
+ assertThatThrownBy(() -> repository.create(resource, requestContext))
+ .isInstanceOf(ResourceException.class)
+ .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(400));
+ }
+
+ @Test
+ @DisplayName("member with DN value (contains =) is validated and passed through")
+ void memberWithDnValue_validatedAndPassedThrough() throws Exception {
+ ScimGroup resource = new ScimGroup();
+ resource.setDisplayName("admins");
+ String memberDn = "uid=jdoe,ou=users,dc=example,dc=com";
+ resource.setMembers(new ArrayList<>(List.of(membership(memberDn))));
+
+ Entry ldapEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(ldapEntry);
+
+ Entry createdEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ createdEntry.add("entryUUID", "new-group-uuid");
+ when(ldapDao.lookup("cn=admins," + GROUP_BASE_DN)).thenReturn(createdEntry);
+
+ ScimGroup mappedResult = new ScimGroup();
+ mappedResult.setId("new-group-uuid");
+ mappedResult.setDisplayName("admins");
+ when(attributeMapper.toScimGroup(createdEntry)).thenReturn(mappedResult);
+
+ ScimGroup result = repository.create(resource, requestContext);
+
+ assertThat(result).isNotNull();
+ // Verify the member DN was kept (not looked up as UUID)
+ verify(ldapDao, never()).searchByAttribute(anyString(), eq("entryUUID"), eq(memberDn));
+ }
+
+ @Test
+ @DisplayName("invalid DN throws ResourceException 400")
+ void invalidDn_throwsResourceException400() throws Exception {
+ ScimGroup resource = new ScimGroup();
+ resource.setDisplayName("admins");
+ resource.setMembers(new ArrayList<>(List.of(membership("not=a=valid=dn=at=all"))));
+
+ assertThatThrownBy(() -> repository.create(resource, requestContext))
+ .isInstanceOf(ResourceException.class)
+ .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(400));
+ }
+
+ @Test
+ @DisplayName("DN not under known base throws ResourceException 400")
+ void dnNotUnderKnownBase_throwsResourceException400() throws Exception {
+ ScimGroup resource = new ScimGroup();
+ resource.setDisplayName("admins");
+ resource.setMembers(new ArrayList<>(List.of(membership("uid=jdoe,ou=unknown,dc=other,dc=com"))));
+
+ assertThatThrownBy(() -> repository.create(resource, requestContext))
+ .isInstanceOf(ResourceException.class)
+ .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(400));
+ }
+ }
+
+ // =========================================================================
+ // update
+ // =========================================================================
+
+ @Nested
+ @DisplayName("update")
+ class UpdateTest {
+
+ @Test
+ @DisplayName("found resolves members, modifies, and returns")
+ void found_resolvesMembers_modifies_returns() throws Exception {
+ String groupId = "group-uuid-1";
+ Entry existingEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ existingEntry.add("entryUUID", groupId);
+ when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", groupId)).thenReturn(existingEntry);
+
+ ScimGroup resource = new ScimGroup();
+ resource.setDisplayName("admins");
+ resource.setMembers(new ArrayList<>(List.of(membership("uid=jdoe,ou=users,dc=example,dc=com"))));
+
+ Entry updatedEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ updatedEntry.add("member", "uid=jdoe,ou=users,dc=example,dc=com");
+ when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(updatedEntry);
+
+ Entry resultEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ resultEntry.add("entryUUID", groupId);
+ when(ldapDao.lookup("cn=admins," + GROUP_BASE_DN)).thenReturn(resultEntry);
+
+ ScimGroup mappedResult = new ScimGroup();
+ mappedResult.setId(groupId);
+ mappedResult.setDisplayName("admins");
+ when(attributeMapper.toScimGroup(resultEntry)).thenReturn(mappedResult);
+
+ ScimGroup result = repository.update(groupId, resource, requestContext);
+
+ assertThat(result).isNotNull();
+ assertThat(result.getId()).isEqualTo(groupId);
+ }
+
+ @Test
+ @DisplayName("not found throws ResourceNotFoundException")
+ void notFound_throwsResourceNotFoundException() throws Exception {
+ when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", "missing-uuid"))
+ .thenReturn(null);
+
+ ScimGroup resource = new ScimGroup();
+ resource.setDisplayName("admins");
+
+ assertThatThrownBy(() -> repository.update("missing-uuid", resource, requestContext))
+ .isInstanceOf(ResourceNotFoundException.class);
+ }
+
+ @Test
+ @DisplayName("displayName changed calls rename before modify")
+ void displayNameChanged_callsRenameBeforeModify() throws Exception {
+ String groupId = "group-uuid-1";
+ Entry existingEntry = new DefaultEntry("cn=old," + GROUP_BASE_DN, "cn: old");
+ existingEntry.add("entryUUID", groupId);
+ when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", groupId)).thenReturn(existingEntry);
+
+ ScimGroup resource = new ScimGroup();
+ resource.setDisplayName("new");
+
+ Entry updatedEntry = new DefaultEntry("cn=new," + GROUP_BASE_DN);
+ when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(updatedEntry);
+
+ Entry resultEntry = new DefaultEntry("cn=new," + GROUP_BASE_DN);
+ resultEntry.add("entryUUID", groupId);
+ when(ldapDao.lookup(anyString())).thenReturn(resultEntry);
+
+ ScimGroup mappedResult = new ScimGroup();
+ mappedResult.setId(groupId);
+ mappedResult.setDisplayName("new");
+ when(attributeMapper.toScimGroup(resultEntry)).thenReturn(mappedResult);
+
+ repository.update(groupId, resource, requestContext);
+
+ verify(ldapDao).rename("cn=old," + GROUP_BASE_DN, "cn=new");
+ }
+
+ @Test
+ @DisplayName("displayName unchanged does not call rename")
+ void displayNameUnchanged_doesNotCallRename() throws Exception {
+ String groupId = "group-uuid-1";
+ Entry existingEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN, "cn: admins");
+ existingEntry.add("entryUUID", groupId);
+ when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", groupId)).thenReturn(existingEntry);
+
+ ScimGroup resource = new ScimGroup();
+ resource.setDisplayName("admins");
+
+ Entry updatedEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(updatedEntry);
+
+ Entry resultEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ resultEntry.add("entryUUID", groupId);
+ when(ldapDao.lookup("cn=admins," + GROUP_BASE_DN)).thenReturn(resultEntry);
+
+ ScimGroup mappedResult = new ScimGroup();
+ mappedResult.setId(groupId);
+ mappedResult.setDisplayName("admins");
+ when(attributeMapper.toScimGroup(resultEntry)).thenReturn(mappedResult);
+
+ repository.update(groupId, resource, requestContext);
+
+ verify(ldapDao, never()).rename(anyString(), anyString());
+ }
+
+ @Test
+ @DisplayName("displayName changed, rename succeeds, modify fails — throws ResourceException")
+ void displayNameChanged_renameSucceeds_modifyFails_throwsResourceException() throws Exception {
+ String groupId = "group-uuid-1";
+ Entry existingEntry = new DefaultEntry("cn=old," + GROUP_BASE_DN, "cn: old");
+ existingEntry.add("entryUUID", groupId);
+ when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", groupId)).thenReturn(existingEntry);
+
+ ScimGroup resource = new ScimGroup();
+ resource.setDisplayName("new");
+
+ // Include a member attribute so buildReplaceModifications produces a modification
+ Entry updatedEntry = new DefaultEntry("cn=new," + GROUP_BASE_DN);
+ updatedEntry.add("member", "uid=jdoe,ou=users,dc=example,dc=com");
+ when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(updatedEntry);
+
+ doThrow(new ResourceException(500, "LDAP modify failed"))
+ .when(ldapDao).modify(anyString(), any(Modification[].class));
+
+ assertThatThrownBy(() -> repository.update(groupId, resource, requestContext))
+ .isInstanceOf(ResourceException.class);
+ }
+ }
+
+ // =========================================================================
+ // delete
+ // =========================================================================
+
+ @Nested
+ @DisplayName("delete")
+ class DeleteTest {
+
+ @Test
+ @DisplayName("found deletes by DN")
+ void found_deletesByDn() throws Exception {
+ Entry groupEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ groupEntry.add("entryUUID", "group-uuid-1");
+ when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", "group-uuid-1"))
+ .thenReturn(groupEntry);
+
+ repository.delete("group-uuid-1");
+
+ verify(ldapDao).delete("cn=admins," + GROUP_BASE_DN);
+ }
+
+ @Test
+ @DisplayName("not found throws ResourceNotFoundException")
+ void notFound_throwsResourceNotFoundException() throws Exception {
+ when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", "missing-uuid"))
+ .thenReturn(null);
+
+ assertThatThrownBy(() -> repository.delete("missing-uuid"))
+ .isInstanceOf(ResourceNotFoundException.class);
+ }
+ }
+
+ // =========================================================================
+ // Member resolution details
+ // =========================================================================
+
+ @Nested
+ @DisplayName("member resolution details")
+ class MemberResolutionTest {
+
+ @Test
+ @DisplayName("UUID member searched in users first then groups")
+ void uuidMember_searchedInUsersFirstThenGroups() throws Exception {
+ ScimGroup resource = new ScimGroup();
+ resource.setDisplayName("admins");
+ resource.setMembers(new ArrayList<>(List.of(membership("some-uuid"))));
+
+ // Not found in users
+ when(ldapDao.searchByAttribute(USER_BASE_DN, "entryUUID", "some-uuid"))
+ .thenReturn(null);
+ // Found in groups
+ Entry groupMemberEntry = new DefaultEntry("cn=subgroup," + GROUP_BASE_DN);
+ groupMemberEntry.add("entryUUID", "some-uuid");
+ when(ldapDao.searchByAttribute(GROUP_BASE_DN, "entryUUID", "some-uuid"))
+ .thenReturn(groupMemberEntry);
+
+ Entry ldapEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(ldapEntry);
+
+ Entry createdEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ createdEntry.add("entryUUID", "new-group-uuid");
+ when(ldapDao.lookup("cn=admins," + GROUP_BASE_DN)).thenReturn(createdEntry);
+
+ ScimGroup mappedResult = new ScimGroup();
+ mappedResult.setId("new-group-uuid");
+ when(attributeMapper.toScimGroup(createdEntry)).thenReturn(mappedResult);
+
+ repository.create(resource, requestContext);
+
+ // Verify users searched first
+ verify(ldapDao).searchByAttribute(USER_BASE_DN, "entryUUID", "some-uuid");
+ // Then groups searched
+ verify(ldapDao).searchByAttribute(GROUP_BASE_DN, "entryUUID", "some-uuid");
+ }
+
+ @Test
+ @DisplayName("null member value is skipped")
+ void nullMemberValue_skipped() throws Exception {
+ ScimGroup resource = new ScimGroup();
+ resource.setDisplayName("admins");
+ GroupMembership nullMember = new GroupMembership();
+ // value is null by default
+ resource.setMembers(new ArrayList<>(List.of(nullMember)));
+
+ Entry ldapEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(ldapEntry);
+
+ Entry createdEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ createdEntry.add("entryUUID", "new-group-uuid");
+ when(ldapDao.lookup("cn=admins," + GROUP_BASE_DN)).thenReturn(createdEntry);
+
+ ScimGroup mappedResult = new ScimGroup();
+ mappedResult.setId("new-group-uuid");
+ when(attributeMapper.toScimGroup(createdEntry)).thenReturn(mappedResult);
+
+ ScimGroup result = repository.create(resource, requestContext);
+
+ assertThat(result).isNotNull();
+ // No searchByAttribute should be called for null member
+ verify(ldapDao, never()).searchByAttribute(anyString(), eq("entryUUID"), any());
+ }
+
+ @Test
+ @DisplayName("member already a DN (contains =) is validated via Dn and base DN check")
+ void memberAlreadyDn_validatedViaDnAndBaseDnCheck() throws Exception {
+ ScimGroup resource = new ScimGroup();
+ resource.setDisplayName("admins");
+ String memberDn = "cn=subgroup,ou=groups,dc=example,dc=com";
+ resource.setMembers(new ArrayList<>(List.of(membership(memberDn))));
+
+ Entry ldapEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ when(attributeMapper.toEntry(any(ScimGroup.class), eq(GROUP_BASE_DN))).thenReturn(ldapEntry);
+
+ Entry createdEntry = new DefaultEntry("cn=admins," + GROUP_BASE_DN);
+ createdEntry.add("entryUUID", "new-group-uuid");
+ when(ldapDao.lookup("cn=admins," + GROUP_BASE_DN)).thenReturn(createdEntry);
+
+ ScimGroup mappedResult = new ScimGroup();
+ mappedResult.setId("new-group-uuid");
+ when(attributeMapper.toScimGroup(createdEntry)).thenReturn(mappedResult);
+
+ ScimGroup result = repository.create(resource, requestContext);
+
+ assertThat(result).isNotNull();
+ // The DN member should not trigger UUID resolution
+ verify(ldapDao, never()).searchByAttribute(anyString(), eq("entryUUID"), eq(memberDn));
+ }
+ }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/service/LdapUserRepositoryTest.java b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/service/LdapUserRepositoryTest.java
new file mode 100644
index 00000000..0ef7a83f
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/java/org/apache/directory/scim/ldap/service/LdapUserRepositoryTest.java
@@ -0,0 +1,497 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one
+* or more contributor license agreements. See the NOTICE file
+* distributed with this work for additional information
+* regarding copyright ownership. The ASF licenses this file
+* to you under the Apache License, Version 2.0 (the
+* "License"); you may not use this file except in compliance
+* with the License. You may obtain a copy of the License at
+
+* http://www.apache.org/licenses/LICENSE-2.0
+
+* Unless required by applicable law or agreed to in writing,
+* software distributed under the License is distributed on an
+* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+* KIND, either express or implied. See the License for the
+* specific language governing permissions and limitations
+* under the License.
+*/
+
+package org.apache.directory.scim.ldap.service;
+
+import org.apache.directory.api.ldap.model.entry.DefaultEntry;
+import org.apache.directory.api.ldap.model.entry.Entry;
+import org.apache.directory.api.ldap.model.entry.Modification;
+import org.apache.directory.scim.core.repository.ScimRequestContext;
+import org.apache.directory.scim.ldap.ldap.LdapDao;
+import org.apache.directory.scim.ldap.ldap.ScimLdapConfig;
+import org.apache.directory.scim.ldap.mapping.AttributeMapper;
+import org.apache.directory.scim.spec.exception.ResourceException;
+import org.apache.directory.scim.spec.exception.ResourceNotFoundException;
+import org.apache.directory.scim.spec.filter.Filter;
+import org.apache.directory.scim.spec.filter.FilterResponse;
+import org.apache.directory.scim.spec.filter.PageRequest;
+import org.apache.directory.scim.spec.resources.ScimUser;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class LdapUserRepositoryTest {
+
+ static final String BASE_DN = "ou=users,dc=example,dc=com";
+
+ LdapDao ldapDao = mock(LdapDao.class);
+ AttributeMapper attributeMapper = mock(AttributeMapper.class);
+ ScimLdapConfig config = mock(ScimLdapConfig.class);
+
+ LdapUserRepository repository;
+
+ @BeforeEach
+ void setUp() {
+ when(config.getUserBaseDn()).thenReturn(BASE_DN);
+ when(attributeMapper.getUserRdnAttribute()).thenReturn("uid");
+
+ repository = new LdapUserRepository(ldapDao, attributeMapper, config);
+ }
+
+ // =========================================================================
+ // find
+ // =========================================================================
+
+ @Nested
+ @DisplayName("find")
+ class FindTest {
+
+ @Test
+ void nullFilterSearchesWithObjectClassFilter() throws Exception {
+ Entry entry1 = new DefaultEntry("uid=alice," + BASE_DN);
+ ScimUser user1 = new ScimUser();
+ user1.setId("uuid-1");
+
+ when(ldapDao.findUsers(any(), any())).thenReturn(new FilterResponse<>(List.of(entry1), 1));
+ when(attributeMapper.toScimUser(entry1)).thenReturn(user1);
+
+ FilterResponse<ScimUser> response = repository.find(null, ScimRequestContext.empty());
+
+ assertThat(response.getResources()).containsExactly(user1);
+ assertThat(response.getTotalResults()).isEqualTo(1);
+ }
+
+ @Test
+ void withFilterDelegatesToFindUsers() throws Exception {
+ Filter filter = new Filter("userName eq \"john\"");
+
+ Entry entry = new DefaultEntry("uid=john," + BASE_DN);
+ ScimUser user = new ScimUser();
+ user.setId("uuid-john");
+
+ when(ldapDao.findUsers(any(), any())).thenReturn(new FilterResponse<>(List.of(entry), 1));
+ when(attributeMapper.toScimUser(entry)).thenReturn(user);
+
+ FilterResponse<ScimUser> response = repository.find(filter, ScimRequestContext.empty());
+
+ assertThat(response.getResources()).containsExactly(user);
+ assertThat(response.getTotalResults()).isEqualTo(1);
+ }
+
+ @Test
+ void returnsAllMappedUsers() throws Exception {
+ Entry entry1 = new DefaultEntry("uid=alice," + BASE_DN);
+ Entry entry2 = new DefaultEntry("uid=bob," + BASE_DN);
+ ScimUser user1 = new ScimUser();
+ user1.setId("uuid-1");
+ ScimUser user2 = new ScimUser();
+ user2.setId("uuid-2");
+
+ when(ldapDao.findUsers(any(), any())).thenReturn(new FilterResponse<>(List.of(entry1, entry2), 2));
+ when(attributeMapper.toScimUser(entry1)).thenReturn(user1);
+ when(attributeMapper.toScimUser(entry2)).thenReturn(user2);
+
+ FilterResponse<ScimUser> response = repository.find(null, ScimRequestContext.empty());
+
+ assertThat(response.getResources()).containsExactly(user1, user2);
+ assertThat(response.getTotalResults()).isEqualTo(2);
+ }
+
+ @Test
+ void emptyResultsReturnsEmptyFilterResponse() throws Exception {
+ when(ldapDao.findUsers(any(), any())).thenReturn(new FilterResponse<>(Collections.emptyList(), 0));
+
+ FilterResponse<ScimUser> response = repository.find(null, ScimRequestContext.empty());
+
+ assertThat(response.getResources()).isEmpty();
+ assertThat(response.getTotalResults()).isEqualTo(0);
+ }
+
+ // ----- Pagination helpers and tests using 5 entries -----
+
+ private static ScimUser newUser(String id) {
+ ScimUser u = new ScimUser();
+ u.setId(id);
+ return u;
+ }
+
+ ScimUser pUser1 = newUser("uuid-p1");
+ ScimUser pUser2 = newUser("uuid-p2");
+ ScimUser pUser3 = newUser("uuid-p3");
+ ScimUser pUser4 = newUser("uuid-p4");
+ ScimUser pUser5 = newUser("uuid-p5");
+
+ Entry pEntry1;
+ Entry pEntry2;
+ Entry pEntry3;
+ Entry pEntry4;
+ Entry pEntry5;
+
+ private void setupEntryMappings() throws Exception {
+ pEntry1 = new DefaultEntry("uid=alice," + BASE_DN);
+ pEntry2 = new DefaultEntry("uid=bob," + BASE_DN);
+ pEntry3 = new DefaultEntry("uid=carol," + BASE_DN);
+ pEntry4 = new DefaultEntry("uid=dave," + BASE_DN);
+ pEntry5 = new DefaultEntry("uid=eve," + BASE_DN);
+
+ when(attributeMapper.toScimUser(pEntry1)).thenReturn(pUser1);
+ when(attributeMapper.toScimUser(pEntry2)).thenReturn(pUser2);
+ when(attributeMapper.toScimUser(pEntry3)).thenReturn(pUser3);
+ when(attributeMapper.toScimUser(pEntry4)).thenReturn(pUser4);
+ when(attributeMapper.toScimUser(pEntry5)).thenReturn(pUser5);
+ }
+
+ @Test
+ void paginationFirstPage() throws Exception {
+ setupEntryMappings();
+
+ when(ldapDao.findUsers(any(), any()))
+ .thenReturn(new FilterResponse<>(List.of(pEntry1, pEntry2), 5));
+
+ ScimRequestContext context = new ScimRequestContext()
+ .setPageRequest(new PageRequest().setStartIndex(1).setCount(2));
+
+ FilterResponse<ScimUser> response = repository.find(null, context);
+
+ assertThat(response.getResources()).containsExactly(pUser1, pUser2);
+ assertThat(response.getTotalResults()).isEqualTo(5);
+ }
+
+ @Test
+ void paginationMiddlePage() throws Exception {
+ setupEntryMappings();
+
+ when(ldapDao.findUsers(any(), any()))
+ .thenReturn(new FilterResponse<>(List.of(pEntry3, pEntry4), 5));
+
+ ScimRequestContext context = new ScimRequestContext()
+ .setPageRequest(new PageRequest().setStartIndex(3).setCount(2));
+
+ FilterResponse<ScimUser> response = repository.find(null, context);
+
+ assertThat(response.getResources()).containsExactly(pUser3, pUser4);
+ assertThat(response.getTotalResults()).isEqualTo(5);
+ }
+
+ @Test
+ void paginationLastPage() throws Exception {
+ setupEntryMappings();
+
+ when(ldapDao.findUsers(any(), any()))
+ .thenReturn(new FilterResponse<>(List.of(pEntry5), 5));
+
+ ScimRequestContext context = new ScimRequestContext()
+ .setPageRequest(new PageRequest().setStartIndex(5).setCount(10));
+
+ FilterResponse<ScimUser> response = repository.find(null, context);
+
+ assertThat(response.getResources()).containsExactly(pUser5);
+ assertThat(response.getTotalResults()).isEqualTo(5);
+ }
+
+ @Test
+ void paginationBeyondResults() throws Exception {
+ setupEntryMappings();
+
+ when(ldapDao.findUsers(any(), any()))
+ .thenReturn(new FilterResponse<>(Collections.emptyList(), 5));
+
+ ScimRequestContext context = new ScimRequestContext()
+ .setPageRequest(new PageRequest().setStartIndex(6).setCount(2));
+
+ FilterResponse<ScimUser> response = repository.find(null, context);
+
+ assertThat(response.getResources()).isEmpty();
+ assertThat(response.getTotalResults()).isEqualTo(5);
+ }
+
+ @Test
+ void noPaginationReturnsAll() throws Exception {
+ setupEntryMappings();
+
+ when(ldapDao.findUsers(any(), any()))
+ .thenReturn(new FilterResponse<>(List.of(pEntry1, pEntry2, pEntry3, pEntry4, pEntry5), 5));
+
+ FilterResponse<ScimUser> response = repository.find(null, ScimRequestContext.empty());
+
+ assertThat(response.getResources()).containsExactly(pUser1, pUser2, pUser3, pUser4, pUser5);
+ assertThat(response.getTotalResults()).isEqualTo(5);
+ }
+
+ @Test
+ void resourceExceptionFromSearchPropagates() throws Exception {
+ when(ldapDao.findUsers(any(), any()))
+ .thenThrow(new ResourceException(503, "LDAP unavailable"));
+
+ assertThatThrownBy(() -> repository.find(null, ScimRequestContext.empty()))
+ .isInstanceOf(ResourceException.class)
+ .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(503));
+ }
+
+ @Test
+ void mappingExceptionWrapsIn500() throws Exception {
+ Entry entry = new DefaultEntry("uid=bad," + BASE_DN);
+ when(ldapDao.findUsers(any(), any())).thenReturn(new FilterResponse<>(List.of(entry), 1));
+ when(attributeMapper.toScimUser(entry)).thenThrow(new RuntimeException("bad attribute"));
+
+ assertThatThrownBy(() -> repository.find(null, ScimRequestContext.empty()))
+ .isInstanceOf(ResourceException.class)
+ .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(500));
+ }
+ }
+
+ // =========================================================================
+ // get
+ // =========================================================================
+
+ @Nested
+ @DisplayName("get")
+ class GetTest {
+
+ @Test
+ void foundReturnsScimUser() throws Exception {
+ Entry entry = new DefaultEntry("uid=alice," + BASE_DN);
+ ScimUser user = new ScimUser();
+ user.setId("uuid-1");
+
+ when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", "uuid-1")).thenReturn(entry);
+ when(attributeMapper.toScimUser(entry)).thenReturn(user);
+
+ ScimUser result = repository.get("uuid-1", ScimRequestContext.empty());
+
+ assertThat(result).isSameAs(user);
+ }
+
+ @Test
+ void notFoundReturnsNull() throws Exception {
+ when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", "missing")).thenReturn(null);
+
+ ScimUser result = repository.get("missing", ScimRequestContext.empty());
+
+ assertThat(result).isNull();
+ }
+
+ @Test
+ void resourceExceptionPropagates() throws Exception {
+ when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", "uuid-1"))
+ .thenThrow(new ResourceException(503, "LDAP unavailable"));
+
+ assertThatThrownBy(() -> repository.get("uuid-1", ScimRequestContext.empty()))
+ .isInstanceOf(ResourceException.class)
+ .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(503));
+ }
+ }
+
+ // =========================================================================
+ // create
+ // =========================================================================
+
+ @Nested
+ @DisplayName("create")
+ class CreateTest {
+
+ @Test
+ void successVerifiesChain() throws Exception {
+ ScimUser input = new ScimUser();
+ input.setUserName("alice");
+
+ Entry newEntry = new DefaultEntry("uid=alice," + BASE_DN);
+ Entry createdEntry = new DefaultEntry("uid=alice," + BASE_DN);
+ ScimUser createdUser = new ScimUser();
+ createdUser.setId("uuid-created");
+
+ when(attributeMapper.toEntry(input, BASE_DN)).thenReturn(newEntry);
+ when(ldapDao.lookup("uid=alice," + BASE_DN)).thenReturn(createdEntry);
+ when(attributeMapper.toScimUser(createdEntry)).thenReturn(createdUser);
+
+ ScimUser result = repository.create(input, ScimRequestContext.empty());
+
+ assertThat(result).isSameAs(createdUser);
+ verify(attributeMapper).toEntry(input, BASE_DN);
+ verify(ldapDao).create(newEntry);
+ verify(ldapDao).lookup("uid=alice," + BASE_DN);
+ verify(attributeMapper).toScimUser(createdEntry);
+ }
+
+ @Test
+ void resourceExceptionPropagates() throws Exception {
+ ScimUser input = new ScimUser();
+ input.setUserName("alice");
+
+ Entry newEntry = new DefaultEntry("uid=alice," + BASE_DN);
+ when(attributeMapper.toEntry(input, BASE_DN)).thenReturn(newEntry);
+ when(ldapDao.lookup(anyString())).thenThrow(new ResourceException(409, "Conflict"));
+
+ // ldapDao.create does not throw, but lookup does
+ assertThatThrownBy(() -> repository.create(input, ScimRequestContext.empty()))
+ .isInstanceOf(ResourceException.class)
+ .satisfies(ex -> assertThat(((ResourceException) ex).getStatus()).isEqualTo(409));
+ }
+ }
+
+ // =========================================================================
+ // update
+ // =========================================================================
+
+ @Nested
+ @DisplayName("update")
+ class UpdateTest {
+
+ @Test
+ void foundVerifiesChain() throws Exception {
+ String id = "uuid-1";
+ ScimUser resource = new ScimUser();
+ resource.setUserName("alice");
+
+ Entry existingEntry = new DefaultEntry("uid=alice," + BASE_DN);
+ Entry updatedEntry = new DefaultEntry("uid=alice," + BASE_DN);
+ Entry resultEntry = new DefaultEntry("uid=alice," + BASE_DN);
+ ScimUser resultUser = new ScimUser();
+ resultUser.setId(id);
+
+ when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", id)).thenReturn(existingEntry);
+ when(attributeMapper.toEntry(resource, BASE_DN)).thenReturn(updatedEntry);
+ when(ldapDao.lookup("uid=alice," + BASE_DN)).thenReturn(resultEntry);
+ when(attributeMapper.toScimUser(resultEntry)).thenReturn(resultUser);
+
+ ScimUser result = repository.update(id, resource, ScimRequestContext.empty());
+
+ assertThat(result).isSameAs(resultUser);
+ verify(ldapDao).searchByAttribute(BASE_DN, "entryUUID", id);
+ verify(attributeMapper).toEntry(resource, BASE_DN);
+ verify(ldapDao).lookup("uid=alice," + BASE_DN);
+ verify(attributeMapper).toScimUser(resultEntry);
+ }
+
+ @Test
+ void notFoundThrowsResourceNotFoundException() throws Exception {
+ when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", "missing")).thenReturn(null);
+
+ assertThatThrownBy(() -> repository.update("missing", new ScimUser(), ScimRequestContext.empty()))
+ .isInstanceOf(ResourceNotFoundException.class);
+ }
+
+ @Test
+ void userNameChanged_callsRenameBeforeModify() throws Exception {
+ String id = "uuid-1";
+ ScimUser resource = new ScimUser();
+ resource.setUserName("new");
+
+ Entry existingEntry = new DefaultEntry("uid=old," + BASE_DN, "uid: old");
+ Entry updatedEntry = new DefaultEntry("uid=new," + BASE_DN);
+ Entry resultEntry = new DefaultEntry("uid=new," + BASE_DN);
+ ScimUser resultUser = new ScimUser();
+ resultUser.setId(id);
+
+ when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", id)).thenReturn(existingEntry);
+ when(attributeMapper.toEntry(resource, BASE_DN)).thenReturn(updatedEntry);
+ when(ldapDao.lookup(anyString())).thenReturn(resultEntry);
+ when(attributeMapper.toScimUser(resultEntry)).thenReturn(resultUser);
+
+ repository.update(id, resource, ScimRequestContext.empty());
+
+ verify(ldapDao).rename("uid=old," + BASE_DN, "uid=new");
+ }
+
+ @Test
+ void userNameUnchanged_doesNotCallRename() throws Exception {
+ String id = "uuid-1";
+ ScimUser resource = new ScimUser();
+ resource.setUserName("alice");
+
+ Entry existingEntry = new DefaultEntry("uid=alice," + BASE_DN, "uid: alice");
+ Entry updatedEntry = new DefaultEntry("uid=alice," + BASE_DN);
+ Entry resultEntry = new DefaultEntry("uid=alice," + BASE_DN);
+ ScimUser resultUser = new ScimUser();
+ resultUser.setId(id);
+
+ when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", id)).thenReturn(existingEntry);
+ when(attributeMapper.toEntry(resource, BASE_DN)).thenReturn(updatedEntry);
+ when(ldapDao.lookup("uid=alice," + BASE_DN)).thenReturn(resultEntry);
+ when(attributeMapper.toScimUser(resultEntry)).thenReturn(resultUser);
+
+ repository.update(id, resource, ScimRequestContext.empty());
+
+ verify(ldapDao, never()).rename(anyString(), anyString());
+ }
+
+ @Test
+ void userNameChanged_renameSucceeds_modifyFails_throwsResourceException() throws Exception {
+ String id = "uuid-1";
+ ScimUser resource = new ScimUser();
+ resource.setUserName("new");
+
+ Entry existingEntry = new DefaultEntry("uid=old," + BASE_DN, "uid: old");
+ // Include a non-RDN, non-objectClass attr so buildReplaceModifications produces a modification
+ Entry updatedEntry = new DefaultEntry("uid=new," + BASE_DN, "cn: New Name");
+
+ when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", id)).thenReturn(existingEntry);
+ when(attributeMapper.toEntry(resource, BASE_DN)).thenReturn(updatedEntry);
+ doThrow(new ResourceException(500, "LDAP modify failed"))
+ .when(ldapDao).modify(anyString(), any(Modification[].class));
+
+ assertThatThrownBy(() -> repository.update(id, resource, ScimRequestContext.empty()))
+ .isInstanceOf(ResourceException.class);
+ }
+ }
+
+ // =========================================================================
+ // delete
+ // =========================================================================
+
+ @Nested
+ @DisplayName("delete")
+ class DeleteTest {
+
+ @Test
+ void foundVerifiesDelete() throws Exception {
+ String id = "uuid-1";
+ Entry entry = new DefaultEntry("uid=alice," + BASE_DN);
+
+ when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", id)).thenReturn(entry);
+
+ repository.delete(id);
+
+ verify(ldapDao).searchByAttribute(BASE_DN, "entryUUID", id);
+ verify(ldapDao).delete("uid=alice," + BASE_DN);
+ }
+
+ @Test
+ void notFoundThrowsResourceNotFoundException() throws Exception {
+ when(ldapDao.searchByAttribute(BASE_DN, "entryUUID", "missing")).thenReturn(null);
+
+ assertThatThrownBy(() -> repository.delete("missing"))
+ .isInstanceOf(ResourceNotFoundException.class);
+ }
+ }
+}
diff --git a/reference-projects/scim-server-ldap/src/test/resources/META-INF/services/org.apache.directory.scim.compliance.junit.EmbeddedServerExtension$ScimTestServer b/reference-projects/scim-server-ldap/src/test/resources/META-INF/services/org.apache.directory.scim.compliance.junit.EmbeddedServerExtension$ScimTestServer
new file mode 100644
index 00000000..016dcc42
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/resources/META-INF/services/org.apache.directory.scim.compliance.junit.EmbeddedServerExtension$ScimTestServer
@@ -0,0 +1,19 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+org.apache.directory.scim.ldap.LdapTestServer
diff --git a/reference-projects/scim-server-ldap/src/test/resources/scim-ldap.yml b/reference-projects/scim-server-ldap/src/test/resources/scim-ldap.yml
new file mode 100644
index 00000000..f318661f
--- /dev/null
+++ b/reference-projects/scim-server-ldap/src/test/resources/scim-ldap.yml
@@ -0,0 +1,64 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+# Test configuration for embedded Apache DS.
+# Adds extensibleObject so custom attributes (scimActive, scimPhoneTypes)
+# are accepted without schema registration.
+
+ldap:
+ embedded: false # Tests use LdapTestServer which manages its own embedded instance
+ host: 127.0.0.1
+ port: 10389
+ bindDn: uid=admin,ou=system
+ bindPassword: secret
+ useTls: false
+ userBaseDn: ou=users,dc=example,dc=com
+ groupBaseDn: ou=groups,dc=example,dc=com
+
+user:
+ objectClasses:
+ - inetOrgPerson
+ - organizationalPerson
+ - person
+ - extensibleObject
+ - top
+ rdnAttribute: uid
+ attributes:
+ userName: uid
+ "name.givenName": givenName
+ "name.familyName": sn
+ "name.formatted": cn
+ displayName: displayName
+ "emails.value": mail
+ "phoneNumbers.value": telephoneNumber
+ "addresses.streetAddress": street
+ "addresses.locality": l
+ "addresses.postalCode": postalCode
+ title: title
+ userType: employeeType
+ password: userPassword
+
+group:
+ objectClasses:
+ - groupOfNames
+ - top
+ rdnAttribute: cn
+ attributes:
+ displayName: cn
+ "members.value": member
diff --git a/scim-coverage/pom.xml b/scim-coverage/pom.xml
index a43c1a6b..73a18882 100644
--- a/scim-coverage/pom.xml
+++ b/scim-coverage/pom.xml
@@ -62,6 +62,10 @@
<groupId>org.apache.directory.scimple</groupId>
<artifactId>scim-client</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.apache.directory.scimple</groupId>
+ <artifactId>scim-server-ldap</artifactId>
+ </dependency>
</dependencies>
<build>
diff --git a/src/spotbugs/excludes.xml b/src/spotbugs/excludes.xml
index cd4597bb..aead5e3c 100644
--- a/src/spotbugs/excludes.xml
+++ b/src/spotbugs/excludes.xml
@@ -66,4 +66,13 @@
<Bug pattern="CT_CONSTRUCTOR_THROW"/>
</Match>
+ <!-- False positive: "password" and "userPassword" are SCIM/LDAP attribute names and config defaults, not credentials -->
+ <Match>
+ <Or>
+ <Class name="org.apache.directory.scim.ldap.mapping.AttributeMapper"/>
+ <Class name="org.apache.directory.scim.ldap.ldap.ScimLdapConfig"/>
+ </Or>
+ <Bug pattern="HARD_CODE_PASSWORD"/>
+ </Match>
+
</FindBugsFilter>