/* * 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 2013-2023 Ping Identity Corporation */ package com.unboundid.directory.sdk.examples; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.MappingIterator; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import com.fasterxml.jackson.databind.ObjectWriter; import com.fasterxml.jackson.databind.node.ObjectNode; import com.unboundid.directory.sdk.broker.api.StoreAdapter; import com.unboundid.directory.sdk.broker.config.StoreAdapterConfig; import com.unboundid.directory.sdk.broker.types.BrokerContext; import com.unboundid.directory.sdk.broker.types.StoreCreateRequest; import com.unboundid.directory.sdk.broker.types.StoreDeleteRequest; import com.unboundid.directory.sdk.broker.types.StoreSearchResultListener; import com.unboundid.directory.sdk.broker.types.StoreRetrieveRequest; import com.unboundid.directory.sdk.broker.types.StoreSearchRequest; import com.unboundid.directory.sdk.broker.types.StoreUpdateRequest; import com.unboundid.directory.sdk.common.types.LogSeverity; import com.unboundid.ldap.sdk.LDAPException; import com.unboundid.ldap.sdk.ResultCode; import com.unboundid.scim2.common.GenericScimResource; 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.filters.Filter; import com.unboundid.scim2.common.messages.PatchRequest; import com.unboundid.scim2.common.utils.FilterEvaluator; import com.unboundid.scim2.common.utils.JsonUtils; import com.unboundid.scim2.common.utils.ScimJsonFactory; import com.unboundid.util.StaticUtils; import com.unboundid.util.args.ArgumentException; import com.unboundid.util.args.ArgumentParser; import com.unboundid.util.args.FileArgument; import java.io.BufferedInputStream; import java.io.Closeable; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; /** * This example provides an implementation of a flat-file StoreAdapter * which stores entries in JSON format. Note that this is a simplistic * implementation for the sake of example; production StoreAdapter * implementations must store their data in persistent storage that is * accessible to all PingAuthorize Server instances. */ public class ExampleStoreAdapter extends StoreAdapter { // Handle to the ServerContext object. private BrokerContext serverContext; // The name of the argument that will be used to specify the path to the JSON // file containing the entries. private static final String ARG_NAME_JSON_FILE_PATH = "json-file-path"; // Handle to the JSON file containing the entries. private File jsonFile; // An in-memory map of SCIM resources based on their ID attribute. private final ConcurrentHashMap<String, GenericScimResource> resources = new ConcurrentHashMap<String, GenericScimResource>(); /** * {@inheritDoc} */ @Override public String getExtensionName() { return "Example Store Adapter"; } /** * {@inheritDoc} */ @Override public String[] getExtensionDescription() { return new String[] { "This example provides an implementation of a flat-file StoreAdapter " + "which stores entries in JSON format. Note that this is a simplistic " + "implementation for the sake of example; production StoreAdapter " + "implementations must store their data in persistent storage that is " + "accessible to all PingAuthorize Server instances." }; } /** * {@inheritDoc} */ @Override public void defineConfigArguments(final ArgumentParser parser) throws ArgumentException { final String description = "The path to the JSON file containing the entries."; final List<File> defaultValues = new ArrayList<File>(1); defaultValues.add(new File("resource/entry-database.json")); final FileArgument jsonFileArg = new FileArgument(null, ARG_NAME_JSON_FILE_PATH, true, 1, "{file-path}", description, false, true, true, false, defaultValues); parser.addArgument(jsonFileArg); } /** * Initializes this store adapter. Any initialization should be performed * here. This method should generally store the * {@link BrokerContext} in * a class member so that it can be used elsewhere in the implementation. * * @param serverContext A handle to the server context for the server in * which this extension is running. Extensions should * typically store this in a class member. * @param config The general configuration for this object. * @param parser The argument parser which has been initialized from * the configuration for this store adapter. * @throws LDAPException If a problem occurs while initializing this store * adapter. */ @Override public void initializeStoreAdapter(final BrokerContext serverContext, final StoreAdapterConfig config, final ArgumentParser parser) throws LDAPException { this.serverContext = serverContext; final FileArgument jsonFileArg = (FileArgument) parser.getNamedArgument(ARG_NAME_JSON_FILE_PATH); jsonFile = jsonFileArg.getValue(); if (!jsonFile.isAbsolute()) { jsonFile = new File(serverContext.getServerRoot(), jsonFile.getPath()); } // Preload the entries from the file if it already exists. if (jsonFile.length() > 0) { BufferedInputStream inputStream = null; try { inputStream = new BufferedInputStream(new FileInputStream(jsonFile)); final MappingIterator<GenericScimResource> nodes = JsonUtils.getObjectReader().forType(GenericScimResource.class). readValues(inputStream); // Populate the resources map. for (GenericScimResource r : nodes.readAll()) { resources.put(r.getId(), r); } } catch(Exception e) { serverContext.debugCaught(e); throw new LDAPException(ResultCode.LOCAL_ERROR, e); } finally { safeCloseStream(inputStream); } } else // Otherwise, create the file. { try { jsonFile.createNewFile(); } catch (IOException ioe) { serverContext.debugCaught(ioe); throw new LDAPException(ResultCode.LOCAL_ERROR, ioe); } } } /** * Determines whether this Store Adapter is currently available and * in-service. This method may return {@code false} in the case that the * JSON file is not accessible; during this time the SCIMResourceType will * return an appropriate 503 response code to clients. * * @return {@code true} if this Store Adapter is initialized and connected; * {@code false} otherwise. */ @Override public boolean isAvailable() { return jsonFile != null && jsonFile.canWrite(); } /** * Fetches the entries that match the specified criteria. * * @param request The search request. * @throws ScimException if there is a problem fulfilling the search request. */ @Override public void search(final StoreSearchRequest request) throws ScimException { final Filter filter = Filter.fromString(request.getSCIMFilter()); final StoreSearchResultListener listener = request.getStoreSearchResultListener(); int resultCount = 0; for (GenericScimResource r : resources.values()) { if (filter == null || FilterEvaluator.evaluate(filter, r.getObjectNode())) { listener.searchResultReturned(r.toString()); resultCount++; if (request.getSizeLimit() > 0 && resultCount >= request.getSizeLimit()) { break; } } } if (serverContext.isTraceMessageLoggable(LogSeverity.INFO)) { serverContext.logTraceMessage( LogSeverity.INFO, String.format("Search returned %d results", resultCount)); } } /** * Fetches the specified entry. * * @param request The retrieve request. * @return The retrieved entry. * @throws ScimException if there is a problem fulfilling the request. */ @Override public String retrieve(final StoreRetrieveRequest request) throws ScimException { final Filter filter = Filter.fromString(request.getSCIMFilter()); final GenericScimResource r = retrieve(filter); if (serverContext.isTraceMessageLoggable(LogSeverity.INFO)) { serverContext.logTraceMessage( LogSeverity.INFO, String.format("Retrieved resource ID '%s'", r.getId())); } return r.toString(); } /** * Create the specified entry in the JSON file. * * @param request The create request. * @return The entry that was just created; * @throws ScimException if there is a problem creating the entry. */ @Override public String create(final StoreCreateRequest request) throws ScimException { // All write operations are synchronized to prevent concurrent modifications // to the JSON file. synchronized(this) { try { final GenericScimResource resourceToCreate = new GenericScimResource(toObjectNode(request.getObjectToCreate())); // Populate the ID attribute. resourceToCreate.setId(UUID.randomUUID().toString()); resources.put(resourceToCreate.getId(), resourceToCreate); // Write the new set of resources to the JSON file. writeResources(); if (serverContext.isTraceMessageLoggable(LogSeverity.INFO)) { serverContext.logTraceMessage( LogSeverity.INFO, String.format("Created resource ID '%s'", resourceToCreate.getId())); } return resourceToCreate.toString(); } catch (Exception e) { serverContext.debugCaught(e); throw new ServerErrorException(StaticUtils.getExceptionMessage(e)); } } } /** * Update the specified entry in the JSON file. * * @param request The update request. * @return The updated entry. * @throws ScimException if there is a problem modifying the entry. */ @Override public String update(final StoreUpdateRequest request) throws ScimException { final Filter filter = Filter.fromString(request.getSCIMFilter()); final GenericScimResource resourceToUpdate = retrieve(filter); // All write operations are synchronized to prevent concurrent modifications // to the JSON file. synchronized(this) { try { final ObjectReader reader = JsonUtils.getObjectReader().forType(PatchRequest.class); final PatchRequest patchOp = reader.readValue(request.getPatchRequest()); patchOp.apply(resourceToUpdate); // Write the updated set of resources to the JSON file. writeResources(); if (serverContext.isTraceMessageLoggable(LogSeverity.INFO)) { serverContext.logTraceMessage( LogSeverity.INFO, String.format("Updated resource ID '%s'", resourceToUpdate.getId())); } return resourceToUpdate.toString(); } catch(Exception e) { serverContext.debugCaught(e); throw new ServerErrorException(StaticUtils.getExceptionMessage(e)); } } } /** * Delete the specified entry from the JSON file. * * @param request The delete request. * @throws ScimException if there is a problem deleting the entry. */ @Override public void delete(final StoreDeleteRequest request) throws ScimException { final Filter filter = Filter.fromString(request.getSCIMFilter()); final GenericScimResource resourceToDelete = retrieve(filter); // All write operations are synchronized to prevent concurrent modifications // to the JSON file. synchronized(this) { try { resources.remove(resourceToDelete.getId()); // Write the updated set of resources to the JSON file. writeResources(); if (serverContext.isTraceMessageLoggable(LogSeverity.INFO)) { serverContext.logTraceMessage( LogSeverity.INFO, String.format("Deleted resource ID '%s'", resourceToDelete.getId())); } } catch(Exception e) { serverContext.debugCaught(e); throw new ServerErrorException(StaticUtils.getExceptionMessage(e)); } } } /** * Closes a stream quietly. * * @param stream to close */ private void safeCloseStream(final Closeable stream) { if (stream != null) { try { stream.close(); } catch (final IOException ioe) { // Ignore. } } } /** * Retrieves a resource identified by a SCIM filter. * * @param filter The SCIM filter identifying a resource to retrieve. * @return The requested resource. * @throws ScimException If the resource was not found. */ private GenericScimResource retrieve(final Filter filter) throws ScimException { for (GenericScimResource r : resources.values()) { if (FilterEvaluator.evaluate(filter, r.getObjectNode())) { return r; } } throw new ResourceNotFoundException(String.format( "No resource found matching filter '%s'", filter.toString())); } /** * Writes the current resources to the JSON file. * @throws IOException if the resources could not be written. */ private void writeResources() throws IOException { final ObjectMapper mapper = new ObjectMapper(new ScimJsonFactory()); mapper.configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false); final File file = this.jsonFile; final File tempFile = new File(file.getAbsolutePath() + ".new"); final File oldFile = new File(file.getAbsolutePath() + ".old"); final OutputStream outputStream = new FileOutputStream(tempFile); try { final ObjectWriter writer = mapper.writer(); for (GenericScimResource r : resources.values()) { writer.writeValue(outputStream, r); } } finally { safeCloseStream(outputStream); } if (oldFile.exists()) { oldFile.delete(); } if (file.exists()) { file.renameTo(oldFile); } tempFile.renameTo(file); } /** * Parse a JSON object in string form into an ObjectNode. * * @param jsonString The string containing a JSON object. * @return The parsed ObjectNode. * @throws IOException if the object could not be parsed. */ private ObjectNode toObjectNode(final String jsonString) throws IOException { return (ObjectNode)JsonUtils.getObjectReader().readTree(jsonString); } }