001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2016 ForgeRock AS.
015 * Portions Copyright 2022 Wren Security.
016 */
017package org.forgerock.opendj.rest2ldap.schema;
018
019import static com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_COMMENTS;
020import static com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_SINGLE_QUOTES;
021import static com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS;
022import static java.util.Collections.emptyList;
023import static org.forgerock.opendj.ldap.schema.Schema.getCoreSchema;
024import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_JSON_IO_ERROR;
025import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.ERR_JSON_PARSE_ERROR;
026import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.ValidationPolicy.LENIENT;
027import static org.forgerock.opendj.rest2ldap.schema.JsonSchema.ValidationPolicy.STRICT;
028import static org.forgerock.util.Options.defaultOptions;
029
030import java.io.IOException;
031import java.io.InputStream;
032import java.util.Collection;
033
034import org.forgerock.i18n.LocalizableMessage;
035import org.forgerock.i18n.LocalizedIllegalArgumentException;
036import org.forgerock.opendj.ldap.ByteString;
037import org.forgerock.opendj.ldap.schema.MatchingRule;
038import org.forgerock.opendj.ldap.schema.MatchingRuleImpl;
039import org.forgerock.opendj.ldap.schema.Schema;
040import org.forgerock.opendj.ldap.schema.SchemaBuilder;
041import org.forgerock.opendj.ldap.schema.Syntax;
042import org.forgerock.util.Function;
043import org.forgerock.util.Option;
044import org.forgerock.util.Options;
045
046import com.fasterxml.jackson.core.JsonFactory;
047import com.fasterxml.jackson.core.JsonProcessingException;
048import com.fasterxml.jackson.databind.ObjectMapper;
049
050/**
051 * Utility methods for obtaining JSON syntaxes and matching rules. See the package documentation for more detail.
052 */
053public final class JsonSchema {
054    /** JSON value validation policies. */
055    public enum ValidationPolicy {
056        /** JSON validation policy requiring strict conformance to RFC 7159. */
057        STRICT(new ObjectMapper()),
058        /**
059         * JSON validation policy requiring conformance to RFC 7159 with the following exceptions: 1) comments are
060         * allowed, 2) single quotes may be used instead of double quotes, and 3) unquoted control characters are
061         * allowed in strings.
062         */
063        LENIENT(new ObjectMapper().enable(ALLOW_COMMENTS)
064                                  .enable(ALLOW_SINGLE_QUOTES)
065                                  .enable(ALLOW_UNQUOTED_CONTROL_CHARS)),
066        /** JSON validation policy which does not perform any validation. */
067        DISABLED(null);
068
069        private final ObjectMapper objectMapper;
070
071        ValidationPolicy(final ObjectMapper objectMapper) {
072            this.objectMapper = objectMapper;
073        }
074
075        final JsonFactory getJsonFactory() {
076            return objectMapper.getFactory();
077        }
078
079        final ObjectMapper getObjectMapper() {
080            return objectMapper;
081        }
082    }
083
084    /**
085     * Schema option controlling syntax validation for JSON based attributes. By default this compatibility option
086     * is set to {@link ValidationPolicy#STRICT}.
087     */
088    public static final Option<ValidationPolicy> VALIDATION_POLICY = Option.withDefault(STRICT);
089    /**
090     * Matching rule option controlling whether JSON string comparisons should be case-sensitive. By default this
091     * compatibility option is set to {@code false} meaning that case will be ignored.
092     * <p>
093     * This option must be provided when constructing a JSON matching rule using {@link
094     * #newJsonQueryEqualityMatchingRuleImpl}, and cannot be overridden at the schema level.
095     */
096    public static final Option<Boolean> CASE_SENSITIVE_STRINGS = Option.withDefault(false);
097    /**
098     * Matching rule option controlling whether JSON string comparisons should ignore white-space. By default this
099     * compatibility option is set to {@code true} meaning that leading and trailing white-space will be ignored and
100     * intermediate white-space will be reduced to a single white-space character.
101     * <p>
102     * This option must be provided when constructing a JSON matching rule using {@link
103     * #newJsonQueryEqualityMatchingRuleImpl}, and cannot be overridden at the schema level.
104     */
105    public static final Option<Boolean> IGNORE_WHITE_SPACE = Option.withDefault(true);
106    /**
107     * Matching rule option controlling which JSON fields should be indexed by the matching rule. By default all
108     * fields will be indexed. To restrict the set of indexed fields specify a list whose values are wild-card
109     * patterns for matching against JSON pointers. Patterns are JSON pointers where "*" represents zero or more
110     * characters in a single path element, and "**" represents any number of path elements. For example:
111     *
112     * <table valign="top">
113     *     <caption></caption>
114     *     <tr><th>Pattern</th><th>Matches</th><th>Doesn't match</th></tr>
115     *     <tr><td>/aaa/bbb/ccc</td><td>/aaa/bbb/ccc</td><td>/aaa/bbb/ccc/ddd<br/>/aaa/bbb/cccc</td></tr>
116     *     <tr><td>/aaa/b&#x002A;/ccc</td><td>/aaa/bbb/ccc<br/>/aaa/bxx/ccc</td><td>/aaa/xxx/ccc<br/>/aaa/bbb</td></tr>
117     *     <tr><td>/aaa/&#x002A;&#x002A;/ddd</td><td>/aaa/ddd<br/>/aaa/xxx/yyy/ddd</td><td>/aaa/bbb/ccc</td></tr>
118     *     <tr><td>/aaa/&#x002A;&#x002A;</td><td>/aaa<br/>/aaa/bbb<br/>/aaa/bbb/ccc<br/></td><td>/aa</td></tr>
119     * </table>
120     */
121    @SuppressWarnings("unchecked")
122    public static final Option<Collection<String>> INDEXED_FIELD_PATTERNS =
123            (Option) Option.of(Collection.class, emptyList());
124    /** The OID of the JSON attribute syntax. */
125    static final String SYNTAX_JSON_OID = "1.3.6.1.4.1.36733.2.1.3.1";
126    /** The description of the JSON attribute syntax. */
127    static final String SYNTAX_JSON_DESCRIPTION = "Json";
128    /** The OID of the JSON query attribute syntax. */
129    static final String SYNTAX_JSON_QUERY_OID = "1.3.6.1.4.1.36733.2.1.3.2";
130    /** The description of the JSON query attribute syntax. */
131    static final String SYNTAX_JSON_QUERY_DESCRIPTION = "Json Query";
132    /** The OID of the case insensitive JSON query equality matching rule. */
133    static final String EMR_CASE_IGNORE_JSON_QUERY_OID = "1.3.6.1.4.1.36733.2.1.4.1";
134    /** The name of the case insensitive JSON query equality matching rule. */
135    static final String EMR_CASE_IGNORE_JSON_QUERY_NAME = "caseIgnoreJsonQueryMatch";
136    /** The OID of the case sensitive JSON query equality matching rule. */
137    static final String EMR_CASE_EXACT_JSON_QUERY_OID = "1.3.6.1.4.1.36733.2.1.4.2";
138    /** The name of the case sensitive JSON query equality matching rule. */
139    static final String EMR_CASE_EXACT_JSON_QUERY_NAME = "caseExactJsonQueryMatch";
140    private static final Syntax JSON_SYNTAX;
141    private static final Syntax JSON_QUERY_SYNTAX;
142    private static final MatchingRule CASE_IGNORE_JSON_QUERY_MATCHING_RULE;
143    private static final MatchingRule CASE_EXACT_JSON_QUERY_MATCHING_RULE;
144    private static final Function<ByteString, Object, LocalizedIllegalArgumentException> BYTESTRING_TO_JSON =
145            new Function<ByteString, Object, LocalizedIllegalArgumentException>() {
146                @Override
147                public Object apply(final ByteString value) {
148                    try (final InputStream inputStream = value.asReader().asInputStream()) {
149                        return LENIENT.getObjectMapper().readValue(inputStream, Object.class);
150                    } catch (final IOException e) {
151                        throw new LocalizedIllegalArgumentException(jsonParsingException(e));
152                    }
153                }
154            };
155
156    static LocalizableMessage jsonParsingException(final IOException e) {
157        if (e instanceof JsonProcessingException) {
158            final JsonProcessingException jpe = (JsonProcessingException) e;
159            if (jpe.getLocation() != null) {
160                return ERR_JSON_PARSE_ERROR.get(jpe.getLocation().getLineNr(),
161                                                jpe.getLocation().getColumnNr(),
162                                                jpe.getOriginalMessage());
163            }
164        }
165        return ERR_JSON_IO_ERROR.get(e.getMessage());
166    }
167
168    private static final Function<Object, ByteString, JsonProcessingException> JSON_TO_BYTESTRING =
169            new Function<Object, ByteString, JsonProcessingException>() {
170                @Override
171                public ByteString apply(final Object value) throws JsonProcessingException {
172                    return ByteString.wrap(LENIENT.getObjectMapper().writeValueAsBytes(value));
173                }
174            };
175
176    static {
177        final Schema schema = addJsonSyntaxesAndMatchingRulesToSchema(new SchemaBuilder(getCoreSchema())).toSchema();
178        JSON_SYNTAX = schema.getSyntax(SYNTAX_JSON_OID);
179        JSON_QUERY_SYNTAX = schema.getSyntax(SYNTAX_JSON_QUERY_OID);
180        CASE_IGNORE_JSON_QUERY_MATCHING_RULE = schema.getMatchingRule(EMR_CASE_IGNORE_JSON_QUERY_OID);
181        CASE_EXACT_JSON_QUERY_MATCHING_RULE = schema.getMatchingRule(EMR_CASE_EXACT_JSON_QUERY_OID);
182    }
183
184    /**
185     * Returns the JSON attribute syntax having the OID 1.3.6.1.4.1.36733.2.1.3.1. Attribute values of this syntax
186     * must be valid JSON. Use the {@link #VALIDATION_POLICY} schema option to control the degree of syntax
187     * enforcement. By default JSON attributes will support equality matching using the
188     * {@link #getCaseIgnoreJsonQueryMatchingRule() jsonQueryMatch} matching rule, although this may be overridden
189     * when defining individual attribute types.
190     *
191     * @return The JSON attribute syntax having the OID 1.3.6.1.4.1.36733.2.1.3.1.
192     */
193    public static Syntax getJsonSyntax() {
194        return JSON_SYNTAX;
195    }
196
197    /**
198     * Returns the JSON Query attribute syntax having the OID 1.3.6.1.4.1.36733.2.1.3.2. Attribute values of this
199     * syntax must be valid CREST JSON {@link org.forgerock.util.query.QueryFilter query filter} strings as
200     * defined in {@link org.forgerock.util.query.QueryFilterParser}.
201     *
202     * @return The JSON Query attribute syntax having the OID 1.3.6.1.4.1.36733.2.1.3.2.
203     */
204    public static Syntax getJsonQuerySyntax() {
205        return JSON_QUERY_SYNTAX;
206    }
207
208    /**
209     * Returns the {@code jsonQueryMatch} matching rule having the OID 1.3.6.1.4.1.36733.2.1.4.1. The
210     * matching rule's assertion syntax is a {@link #getJsonQuerySyntax() CREST JSON query filter}. This matching
211     * rule will ignore case when comparing JSON strings as well as ignoring white-space. In addition, all JSON
212     * fields will be indexed if indexing is enabled.
213     *
214     * @return The @code jsonQueryMatch} matching rule having the OID 1.3.6.1.4.1.36733.2.1.4.1.
215     */
216    public static MatchingRule getCaseIgnoreJsonQueryMatchingRule() {
217        return CASE_IGNORE_JSON_QUERY_MATCHING_RULE;
218    }
219
220    /**
221     * Returns the {@code jsonQueryMatch} matching rule having the OID 1.3.6.1.4.1.36733.2.1.4.2. The
222     * matching rule's assertion syntax is a {@link #getJsonQuerySyntax() CREST JSON query filter}. This matching
223     * rule will ignore case when comparing JSON strings as well as ignoring white-space. In addition, all JSON
224     * fields will be indexed if indexing is enabled.
225     *
226     * @return The @code jsonQueryMatch} matching rule having the OID 1.3.6.1.4.1.36733.2.1.4.2.
227     */
228    public static MatchingRule getCaseExactJsonQueryMatchingRule() {
229        return CASE_EXACT_JSON_QUERY_MATCHING_RULE;
230    }
231
232    /**
233     * Creates a new custom JSON query equality matching rule implementation with the provided matching rule name and
234     * options. This method should be used when creating custom JSON matching rules whose behavior differs from
235     * {@link #getCaseIgnoreJsonQueryMatchingRule()}.
236     *
237     * @param matchingRuleName
238     *         The name of the matching rule. This will be used as the index ID in attribute indexes so it must not
239     *         collide with other indexes identifiers.
240     * @param options
241     *         The options controlling the behavior of the matching rule.
242     * @return The new custom JSON query equality matching rule implementation.
243     * @see #CASE_SENSITIVE_STRINGS
244     * @see #IGNORE_WHITE_SPACE
245     */
246    public static MatchingRuleImpl newJsonQueryEqualityMatchingRuleImpl(final String matchingRuleName,
247                                                                        final Options options) {
248        return new JsonQueryEqualityMatchingRuleImpl(matchingRuleName, options);
249    }
250
251    /**
252     * Adds the syntaxes and matching rules required by for JSON attribute support to the provided schema builder.
253     *
254     * @param builder
255     *         The schema builder to which the schema elements should be added.
256     * @return The schema builder.
257     */
258    public static SchemaBuilder addJsonSyntaxesAndMatchingRulesToSchema(final SchemaBuilder builder) {
259        builder.buildSyntax(SYNTAX_JSON_OID)
260               .description(SYNTAX_JSON_DESCRIPTION)
261               .implementation(new JsonSyntaxImpl())
262               .extraProperties("X-ORIGIN", "OpenDJ Directory Server")
263               .addToSchema();
264
265        builder.buildSyntax(SYNTAX_JSON_QUERY_OID)
266               .description(SYNTAX_JSON_QUERY_DESCRIPTION)
267               .implementation(new JsonQuerySyntaxImpl())
268               .extraProperties("X-ORIGIN", "OpenDJ Directory Server")
269               .addToSchema();
270
271        final JsonQueryEqualityMatchingRuleImpl caseIgnoreImpl = new JsonQueryEqualityMatchingRuleImpl(
272                EMR_CASE_IGNORE_JSON_QUERY_NAME,
273                defaultOptions().set(CASE_SENSITIVE_STRINGS, false).set(IGNORE_WHITE_SPACE, true));
274        builder.buildMatchingRule(EMR_CASE_IGNORE_JSON_QUERY_OID)
275               .names(EMR_CASE_IGNORE_JSON_QUERY_NAME)
276               .syntaxOID(SYNTAX_JSON_QUERY_OID)
277               .extraProperties("X-ORIGIN", "OpenDJ Directory Server")
278               .implementation(caseIgnoreImpl)
279               .addToSchema();
280
281        final JsonQueryEqualityMatchingRuleImpl caseExactImpl = new JsonQueryEqualityMatchingRuleImpl(
282                EMR_CASE_EXACT_JSON_QUERY_NAME,
283                defaultOptions().set(CASE_SENSITIVE_STRINGS, true).set(IGNORE_WHITE_SPACE, true));
284        builder.buildMatchingRule(EMR_CASE_EXACT_JSON_QUERY_OID)
285               .names(EMR_CASE_EXACT_JSON_QUERY_NAME)
286               .syntaxOID(SYNTAX_JSON_QUERY_OID)
287               .extraProperties("X-ORIGIN", "OpenDJ Directory Server")
288               .implementation(caseExactImpl)
289               .addToSchema();
290
291        return builder;
292    }
293
294    /**
295     * Returns a function which parses {@code JSON} values. Invalid values will result in a
296     * {@code LocalizedIllegalArgumentException}.
297     *
298     * @return A function which parses {@code JSON} values.
299     */
300    public static Function<ByteString, Object, LocalizedIllegalArgumentException> byteStringToJson() {
301        return BYTESTRING_TO_JSON;
302    }
303
304    /**
305     * Returns a function which converts a JSON {@code Object} to a {@code ByteString}.
306     *
307     * @return A function which converts a JSON {@code Object} to a {@code ByteString}.
308     */
309    public static Function<Object, ByteString, JsonProcessingException> jsonToByteString() {
310        return JSON_TO_BYTESTRING;
311    }
312
313    private JsonSchema() {
314        // Prevent instantiation.
315    }
316}