/* * CDDL HEADER START * * The contents of this file are subject to the terms of the * Common Development and Distribution License, Version 1.0 only * (the "License"). You may not use this file except in compliance * with the License. * * You can obtain a copy of the license at * docs/licenses/cddl.txt * or http://www.opensource.org/licenses/cddl1.php. * See the License for the specific language governing permissions * and limitations under the License. * * When distributing Covered Code, include this CDDL HEADER in each * file and include the License file at * docs/licenses/cddl.txt. If applicable, * add the following below this CDDL HEADER, with the fields enclosed * by brackets "[]" replaced with your own identifying information: * Portions Copyright [yyyy] [name of copyright owner] * * CDDL HEADER END * * * Portions Copyright 2021-2024 Ping Identity Corporation */ package com.unboundid.directory.sdk.examples; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import com.unboundid.directory.sdk.common.types.LogSeverity; import com.unboundid.directory.sdk.scim2.api.SCIMSubResourceTypeHandler; import com.unboundid.directory.sdk.scim2.config.SCIMSubResourceTypeHandlerConfig; import com.unboundid.directory.sdk.scim2.types.SCIMCreateRequest; import com.unboundid.directory.sdk.scim2.types.SCIMDeleteRequest; import com.unboundid.directory.sdk.scim2.types.SCIMLDAPAttributeMapper; import com.unboundid.directory.sdk.scim2.types.SCIMLDAPInterface; import com.unboundid.directory.sdk.scim2.types.SCIMModifyRequest; import com.unboundid.directory.sdk.scim2.types.SCIMReplaceRequest; import com.unboundid.directory.sdk.scim2.types.SCIMRetrieveRequest; import com.unboundid.directory.sdk.scim2.types.SCIMSearchRequest; import com.unboundid.directory.sdk.scim2.types.SCIMSearchResultListener; import com.unboundid.directory.sdk.scim2.types.SCIMServerContext; import com.unboundid.ldap.sdk.AddRequest; import com.unboundid.ldap.sdk.Control; import com.unboundid.ldap.sdk.DeleteRequest; import com.unboundid.ldap.sdk.Entry; import com.unboundid.ldap.sdk.Filter; import com.unboundid.ldap.sdk.LDAPException; import com.unboundid.ldap.sdk.LDAPResult; import com.unboundid.ldap.sdk.Modification; import com.unboundid.ldap.sdk.ModifyRequest; import com.unboundid.ldap.sdk.ResultCode; import com.unboundid.ldap.sdk.SearchRequest; import com.unboundid.ldap.sdk.SearchResult; import com.unboundid.ldap.sdk.SearchResultEntry; import com.unboundid.ldap.sdk.SearchResultListener; import com.unboundid.ldap.sdk.SearchResultReference; import com.unboundid.ldap.sdk.SearchScope; import com.unboundid.ldap.sdk.controls.PostReadRequestControl; import com.unboundid.ldap.sdk.controls.PostReadResponseControl; import com.unboundid.ldap.sdk.schema.Schema; import com.unboundid.ldap.sdk.unboundidds.controls.NameWithEntryUUIDRequestControl; import com.unboundid.scim2.common.GenericScimResource; import com.unboundid.scim2.common.ScimResource; import com.unboundid.scim2.common.exceptions.ResourceNotFoundException; import com.unboundid.scim2.common.exceptions.ScimException; import com.unboundid.scim2.common.exceptions.ServerErrorException; import com.unboundid.scim2.common.messages.PatchRequest; import com.unboundid.scim2.common.types.SchemaResource; import com.unboundid.scim2.common.utils.JsonUtils; import com.unboundid.util.StaticUtils; import com.unboundid.util.args.ArgumentParser; import javax.servlet.http.HttpServletRequest; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.StringJoiner; /** * This is an example SCIM Sub-Resource Type Handler that demonstrates how to * implement a <i>one-to-many</i> SCIM sub-resource type, which is a SCIM * sub-resource type that may have zero to many sub-resource per parent SCIM * resource. * <p> * Sub-resources provided by this example represent a user's "gadgets" and use * the core SCIM schema "urn:pingidentity:schemas:2.0:GadgetExample". Each * sub-resource is stored in the LDAP directory as a child entry of the LDAP * entry representing the parent SCIM resource using the "device" object class. * For example, given a SCIM resource backed by the LDAP entry * "entryUUID=...,ou=people,dc=example,dc=com" (entryUUID omitted for brevity), * a sub-resource would be stored in the LDAP entry * "entryUUID=...,entryUUID=...,ou=people,dc=example,dc=com". * * This example SCIM Sub-Resource Type Handler requires that its SCIM schema * be defined in the server configuration using the following commands: * <pre> * dsconfig create-scim-schema \ * --schema-name urn:pingidentity:schemas:2.0:GadgetExample \ * --set display-name:Gadget * dsconfig create-scim-attribute \ * --schema-name urn:pingidentity:schemas:2.0:GadgetExample \ * --attribute-name name --set required:true * dsconfig create-scim-attribute \ * --schema-name urn:pingidentity:schemas:2.0:GadgetExample \ * --attribute-name description * dsconfig create-scim-attribute \ * --schema-name urn:pingidentity:schemas:2.0:GadgetExample \ * --attribute-name serialNumber * </pre> */ public class ExampleOneToManySCIMSubResourceTypeHandler extends SCIMSubResourceTypeHandler { /** * The schema URN of this sub-resource type's core schema. */ public static final String SCIM_SCHEMA_ID = "urn:pingidentity:schemas:2.0:GadgetExample"; /** * The name of this sub-resource type's core schema. */ public static final String SCIM_SCHEMA_NAME = "Gadget"; // The LDAP attribute used for SCIM sub-resource IDs. private static final String SUBRESOURCE_ID_ATTRIBUTE = "entryUUID"; // The LDAP object class used for SCIM sub-resource entries. private static final String LDAP_OBJECT_CLASS = "device"; private static final ObjectMapper objectMapper = JsonUtils.createObjectMapper(); private SCIMServerContext serverContext; private SchemaResource coreSchema; private String parentResourceType; private SCIMLDAPInterface ldapInterface; private SCIMLDAPAttributeMapper attributeMapper; /** * {@inheritDoc} */ @Override public String getExtensionName() { return "Example One-To-Many SCIM Sub-Resource Type Handler"; } /** * {@inheritDoc} */ @Override public String[] getExtensionDescription() { return new String[] { "This is an example SCIM Sub-Resource Type Handler that exposes " + "sub-resources representing a user's gadgets.", "The attributes of each gadget sub-resource is stored as a child " + "LDAP entry of the parent resource's entry using the 'device' " + "object class." }; } /** * Initializes this SCIM Sub-Resource Type Handler, loading the SCIM schema * from the server configuration, setting up helper objects, and checking * that the LDAP server supports certain required controls. * * @param serverContext A handle to the server context for the server in * which this extension is running. * @param config The configuration for this SCIM Sub-Resource Type * Handler. * @param parser The argument parser which has been initialized from * the configuration for this SCIM Sub-Resource Type * Handler. * @throws Exception If a problem occurs while initializing this SCIM * Sub-Resource Type Handler. */ @Override public void initializeHandler(final SCIMServerContext serverContext, final SCIMSubResourceTypeHandlerConfig config, final ArgumentParser parser) throws Exception { this.serverContext = serverContext; this.coreSchema = getSchemaFromConfiguration(); parentResourceType = config.getParentSCIMResourceTypeName(); try { ldapInterface = serverContext.getSCIMLDAPInterface(parentResourceType); attributeMapper = serverContext.getLDAPAttributeMapper( ldapInterface.getSchema(), objectMapper); checkForRequiredControls(); } catch (ScimException e) { String error = String.format("Initialization error: %s; Stack trace: %s", e.getMessage(), StaticUtils.getStackTrace(e)); serverContext.logTraceMessage(LogSeverity.DEBUG, error); throw new LDAPException(ResultCode.LOCAL_ERROR, e); } } /** * Gets the SCIM Sub-Resource Type's core schema. * * @return The core schema for the SCIM Sub-Resource Type. */ @Override public SchemaResource getCoreSchema() { return coreSchema; } /** * Indicates that this SCIM Sub-Resource Type Handler supports multiple * sub-resources per parent resource. * * @return Always returns true to indicate that this SCIM Sub-Resource Type * Handler supports multiple sub-resources per parent resource. */ @Override public boolean supportsOneToMany() { // This handler supports multiple sub-resources per parent SCIM resource. return true; } /** * Handles SCIM create requests. * * @param request A SCIM create request. * @return The created sub-resource. * @throws ScimException If an error occurs. */ @Override public ScimResource create(final SCIMCreateRequest request) throws ScimException { // Get the parent LDAP entry's DN. This will be used as the base DN of the // LDAP entry to create. String parentDN = getParentEntryDN(request.getHttpServletRequest(), request.getResourceId()); // Use the SCIM request body to create a LDAP entry for the new // sub-resource. Entry entryToCreate = scimResourceToLDAPEntry(attributeMapper, newEntryDN(parentDN), request.getResourceToCreate()); Entry createdEntry = addEntry(request.getHttpServletRequest(), entryToCreate); // Convert the new entry to a ScimResource. return ldapEntryToSCIMResource(attributeMapper, createdEntry); } /** * Handles SCIM retrieve requests. * * @param request A SCIM retrieve request. * @return The retrieved sub-resource. * @throws ScimException If an error occurs. */ @Override public ScimResource retrieve(final SCIMRetrieveRequest request) throws ScimException { // Get the sub-resource's LDAP entry. Entry entry = getSubResourceEntry(request.getHttpServletRequest(), request.getResourceId(), request.getSubResourceId()); // Convert the entry to a ScimResource. return ldapEntryToSCIMResource(attributeMapper, entry); } /** * Handles SCIM modify requests. * * @param request A SCIM modify request. * @return The modified sub-resource. * @throws ScimException If an error occurs. */ @Override public ScimResource modify(final SCIMModifyRequest request) throws ScimException { // Get the sub-resource's current LDAP entry. Entry currentEntry = getSubResourceEntry(request.getHttpServletRequest(), request.getResourceId(), request.getSubResourceId()); // Convert the current entry to a ScimResource and apply the // PatchRequest to it. ScimResource currentSubResource = ldapEntryToSCIMResource(attributeMapper, currentEntry); PatchRequest patchRequest = request.getPatchRequest(); patchRequest.apply(currentSubResource.asGenericScimResource()); // Convert the modified ScimResource to an LDAP entry with the changes. Entry updatedEntry = scimResourceToLDAPEntry(attributeMapper, currentEntry.getDN(), currentSubResource); // Use the current entry and the updated entry to generate a set of // modifications and apply them over LDAP, then convert the result to a // ScimResource. return update(request.getHttpServletRequest(), currentEntry, updatedEntry); } /** * Handles SCIM replace requests. * * @param request A SCIM replace request. * @return The replaced sub-resource. * @throws ScimException If an error occurs. */ @Override public ScimResource replace(final SCIMReplaceRequest request) throws ScimException { // Get the sub-resource's current LDAP entry. Entry currentEntry = getSubResourceEntry(request.getHttpServletRequest(), request.getResourceId(), request.getSubResourceId()); // Create an LDAP entry representation of the SCIM request body containing // the changes. Entry updatedEntry = scimResourceToLDAPEntry(attributeMapper, currentEntry.getDN(), request.getRequestResource()); // Use the current entry and the updated entry to generate a set of // modifications and apply them over LDAP, then convert the result to a // ScimResource. return update(request.getHttpServletRequest(), currentEntry, updatedEntry); } /** * Handles SCIM delete requests. * * @param request A SCIM delete request. * @throws ScimException If an error occurs. */ @Override public void delete(final SCIMDeleteRequest request) throws ScimException { // Get the sub-resource's current LDAP entry. Entry entry = getSubResourceEntry(request.getHttpServletRequest(), request.getResourceId(), request.getSubResourceId()); // Delete the LDAP entry. DeleteRequest deleteRequest = new DeleteRequest(entry.getDN()); ldapInterface.delete(request.getHttpServletRequest(), deleteRequest); } /** * Handles SCIM search requests. * * @param request A SCIM search request. * @throws ScimException If an error occurs. */ @Override public void search(final SCIMSearchRequest request) throws ScimException { // Get the parent LDAP entry's DN. This will be used as the base DN for // the sub-resource search. String parentDN = getParentEntryDN(request.getHttpServletRequest(), request.getResourceId()); // Create a SCIMSearchResultListenerAdapter to wrap the // SCIMSearchResultListener provided by the server. This will return // results back to the server as they are received over the LDAP interface. SCIMSearchResultListenerAdapter ldapSearchResultListener = new SCIMSearchResultListenerAdapter(ldapInterface.getSchema(), objectMapper, serverContext, request.getSearchResultListener()); // Provide an LDAP filter that simply returns all child entries with the // "device" object class rather than attempting to map the SCIM filter from // the SCIMSearchRequest to an LDAP filter. When result sets are expected // to be small, this is acceptable, because the server will apply the SCIM // filter to the SCIMSearchResultListener's result set. Filter filter = Filter.createEqualityFilter("objectClass", LDAP_OBJECT_CLASS); SearchRequest searchRequest = new SearchRequest(ldapSearchResultListener, parentDN, SearchScope.SUB, filter, "*", "+"); ldapInterface.search(request.getHttpServletRequest(), searchRequest); } /** * Throws an exception if the LDAP server backing this SCIM Sub-Resource Type * does not support the LDAP controls used by this handler. * * @throws ScimException If the LDAP server does not support the LDAP * controls used by this handler. */ private void checkForRequiredControls() throws ScimException { Set<String> missingRequiredControls = new LinkedHashSet<>(); if (!ldapInterface.getSupportedControls().contains( PostReadRequestControl.POST_READ_REQUEST_OID)) { missingRequiredControls.add("Post-Read Request Control"); } if (!ldapInterface.getSupportedControls().contains( NameWithEntryUUIDRequestControl.NAME_WITH_ENTRY_UUID_REQUEST_OID)) { missingRequiredControls.add("Name with entryUUID Request Control"); } if (!missingRequiredControls.isEmpty()) { String errorPrefix = "Cannot initialize extension because the LDAP " + "server does not support the following control(s): "; StringJoiner joiner = new StringJoiner(",", errorPrefix, ""); for (String control : missingRequiredControls) { joiner.add(control); } throw new ServerErrorException(joiner.toString()); } } /** * Retrieves this handler's SCIM schema from the server configuration. * * @return The SCIM schema used by this handler. * @throws LDAPException If the SCIM schema does not exist. */ private SchemaResource getSchemaFromConfiguration() throws LDAPException { Optional<SchemaResource> configuredSchema = serverContext.getSCIMSchemas() .stream() .filter(schema -> schema.getId().equals(SCIM_SCHEMA_ID)) .findFirst(); return configuredSchema.orElseThrow( () -> new LDAPException(ResultCode.LOCAL_ERROR, String.format("Could not find required SCIM schema '%s'", SCIM_SCHEMA_ID))); } /** * Retrieves the DN of the LDAP entry representing a SCIM parent resource. * * @param httpServletRequest The HTTP servlet request associated with the * current operation. * @param scimResourceID The SCIM resource ID. * This must not be null. * * @return The SCIM parent resource's LDAP entry DN. * @throws ScimException If the entry cannot be retrieved. */ private String getParentEntryDN(final HttpServletRequest httpServletRequest, final String scimResourceID) throws ScimException { Filter filter = Filter.createEqualityFilter( serverContext.getIDAttribute(parentResourceType), scimResourceID); SearchRequest searchRequest = new SearchRequest(ldapInterface.getBaseDN(), SearchScope.SUB, filter, "1.1"); Entry entry = ldapInterface.searchForEntry(httpServletRequest, searchRequest); return entry.getDN(); } /** * Retrieves the LDAP entry representing a SCIM sub-resource. * * @param httpServletRequest The HTTP servlet request associated with the * current operation. * @param resourceId The parent SCIM resource ID. * @param subResourceId The SCIM sub-resource ID. * @return The SCIM sub-resource's LDAP entry. * @throws ScimException If the entry cannot be retrieved. */ private Entry getSubResourceEntry(final HttpServletRequest httpServletRequest, final String resourceId, final String subResourceId) throws ScimException { String parentDN = getParentEntryDN(httpServletRequest, resourceId); Filter filter = Filter.createEqualityFilter(SUBRESOURCE_ID_ATTRIBUTE, subResourceId); SearchRequest searchRequest = new SearchRequest(parentDN, SearchScope.SUB, filter, "*", "+"); SearchResult result = ldapInterface.search(httpServletRequest, searchRequest); if (result.getEntryCount() == 0) { throw new ResourceNotFoundException( String.format("SCIM sub-resource %s not found", subResourceId)); } return result.getSearchEntries().get(0); } /** * Gets a DN to be used for creating a new SCIM sub-resource's LDAP entry. * * @param parentDN The parent SCIM resource's LDAP DN. * * @return The new DN. */ private String newEntryDN(final String parentDN) { // Use a placeholder entryUUID value. When adding an entry, the Name with // EntryUUID request control will cause the LDAP server to generate a // unique value. return "entryUUID=00000000-0000-0000-0000-000000000000," + parentDN; } /** * Adds a new entry via LDAP. * * @param httpServletRequest The HTTP servlet request associated with the * current operation. * @param entryToAdd The LDAP entry to add. This entry is expected * to use entryUUID as its RDN attribute. Use * {@link #newEntryDN(String)} to create an * appropriate DN for the entry to be added. * * @return The newly created LDAP entry. * @throws ScimException If the entry cannot be added. */ private Entry addEntry(final HttpServletRequest httpServletRequest, final Entry entryToAdd) throws ScimException { AddRequest addRequest = new AddRequest(entryToAdd); addRequest.addControl(new NameWithEntryUUIDRequestControl()); addRequest.addControl(new PostReadRequestControl("*", "+")); LDAPResult result = ldapInterface.add(httpServletRequest, addRequest); PostReadResponseControl postReadResponseControl = getPostReadResponseControl(result); return postReadResponseControl.getEntry(); } /** * Updates a SCIM sub-resource using two LDAP representations of the * sub-resource: The LDAP entry without changes, and the LDAP entry with * changes. * * @param httpServletRequest The HTTP servlet request associated with the * current operation. * @param currentEntry The current LDAP entry. * @param updatedEntry The same LDAP entry with changes. * * @return The SCIM resource after applying changes. If no * modifications needed to be made, then an * unchanged resource will be returned. * @throws ScimException If the update fails. */ private ScimResource update(final HttpServletRequest httpServletRequest, final Entry currentEntry, final Entry updatedEntry) throws ScimException { // Generate a list of LDAP modifications. List<Modification> ldapMods = Entry.diff(currentEntry, updatedEntry, true, "cn", "description", "serialNumber"); // If there are modifications to make, continue and perform and LDAP modify. if (!ldapMods.isEmpty()) { ModifyRequest modifyRequest = new ModifyRequest(currentEntry.getDN(), ldapMods); modifyRequest.addControl(new PostReadRequestControl("*", "+")); LDAPResult result = ldapInterface.modify(httpServletRequest, modifyRequest); // Use the Post-Read Response Control to get the updated LDAP entry. PostReadResponseControl postReadResponseControl = getPostReadResponseControl(result); return ldapEntryToSCIMResource(attributeMapper, postReadResponseControl.getEntry()); } // If there were no modifications to make, return the unchanged resource. return ldapEntryToSCIMResource(attributeMapper, currentEntry); } /** * Extracts the Post-Read Response Control from an LDAP result. * * @param result An LDAP result containing a Post-Read Response * Control. * * @return The Post-Read Response Control. * @throws ScimException If a Post-Read Response Control cannot be obtained. */ private PostReadResponseControl getPostReadResponseControl( final LDAPResult result) throws ScimException { Control control = result.getResponseControl(PostReadRequestControl.POST_READ_REQUEST_OID); try { if (control == null) { throw new LDAPException(ResultCode.CONTROL_NOT_FOUND, "LDAP result did not include Post-Read Response Control"); } if (control instanceof PostReadResponseControl) { return (PostReadResponseControl) control; } return new PostReadResponseControl(control.getOID(), control.isCritical(), control.getValue()); } catch (LDAPException e) { String error = String.format("Error reading Post-Read Response Control " + "from LDAP result: %s; Stack trace: %s", e.getExceptionMessage(), StaticUtils.getStackTrace(e)); serverContext.logTraceMessage(LogSeverity.SEVERE_ERROR, error); throw new ServerErrorException(error, null, e); } } /** * Converts a SCIM gadget sub-resource object to an LDAP device entry. * * @param attributeMapper An SCIMLDAPAttributeMapper instance. * @param dn The DN to use for the LDAP entry. * @param resource The SCIM resource. * * @return The LDAP entry. * @throws ScimException If an attribute value cannot be converted. */ private static Entry scimResourceToLDAPEntry( final SCIMLDAPAttributeMapper attributeMapper, final String dn, final ScimResource resource) throws ScimException { ObjectNode fields = resource.asGenericScimResource().getObjectNode(); Entry entry = new Entry(dn); entry.setAttribute("objectClass", "top", LDAP_OBJECT_CLASS); entry.setAttribute(attributeMapper.toLdapAttribute( fields.path("name"), "name", "cn")); if (!fields.path("description").isMissingNode()) { entry.setAttribute(attributeMapper.toLdapAttribute( fields.path("description"), "description", "description")); } if (!fields.path("serialNumber").isMissingNode()) { entry.setAttribute(attributeMapper.toLdapAttribute( fields.path("serialNumber"), "serialNumber", "serialNumber")); } return entry; } /** * Converts an LDAP device entry to a SCIM gadget sub-resource object. * * @param attributeMapper An SCIMLDAPAttributeMapper instance. * @param entry The LDAP entry. * * @return The SCIM resource. * @throws ScimException If an attribute value cannot be converted. */ private static ScimResource ldapEntryToSCIMResource( final SCIMLDAPAttributeMapper attributeMapper, final Entry entry) throws ScimException { ObjectNode objectNode = JsonUtils.getJsonNodeFactory().objectNode(); TextNode name = attributeMapper.toTextNode(entry.getAttribute("cn")); objectNode.replace("name", name); if (entry.getAttribute("description") != null) { TextNode description = attributeMapper.toTextNode(entry.getAttribute("description")); objectNode.replace("description", description); } if (entry.getAttribute("serialNumber") != null) { TextNode serialNumber = attributeMapper.toTextNode(entry.getAttribute("serialNumber")); objectNode.replace("serialNumber", serialNumber); } GenericScimResource resource = new GenericScimResource(objectNode); resource.setSchemaUrns(Collections.singletonList(SCIM_SCHEMA_ID)); if (entry.getAttributeValue(SUBRESOURCE_ID_ATTRIBUTE) != null) { resource.setId(entry.getAttributeValue(SUBRESOURCE_ID_ATTRIBUTE)); } return resource; } /** * An LDAP search result listener implementation that maps its search results * to a {@link SCIMSearchResultListener}. */ private static class SCIMSearchResultListenerAdapter implements SearchResultListener { private static final long serialVersionUID = 3211616264384619807L; private final SCIMServerContext serverContext; private final SCIMLDAPAttributeMapper attributeMapper; private final SCIMSearchResultListener scimSearchResultListener; /** * Constructs a SCIMSearchResultListenerAdapter. * * @param ldapSchema The LDAP server schema. * @param objectMapper A Jackson object mapper. * @param serverContext The extension's server context. * @param scimSearchResultListener The SCIM search result listener * associated with the current operation. */ SCIMSearchResultListenerAdapter( final Schema ldapSchema, final ObjectMapper objectMapper, final SCIMServerContext serverContext, final SCIMSearchResultListener scimSearchResultListener) { this.serverContext = serverContext; this.attributeMapper = serverContext.getLDAPAttributeMapper(ldapSchema, objectMapper); this.scimSearchResultListener = scimSearchResultListener; } /** * {@inheritDoc} */ @Override public void searchEntryReturned(final SearchResultEntry searchResultEntry) { try { scimSearchResultListener.searchResultReturned( ldapEntryToSCIMResource(attributeMapper, searchResultEntry)); } catch (ScimException e) { serverContext.logTraceMessage(LogSeverity.SEVERE_ERROR, String.format("Unable to map search result entry '%s' " + "to a ScimResource", searchResultEntry.getDN())); } } /** * {@inheritDoc} */ @Override public void searchReferenceReturned( final SearchResultReference searchResultReference) { // No implementation needed. } } }