001/*
002 * Copyright 2015-2020 Ping Identity Corporation
003 *
004 * This program is free software; you can redistribute it and/or modify
005 * it under the terms of the GNU General Public License (GPLv2 only)
006 * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
007 * as published by the Free Software Foundation.
008 *
009 * This program is distributed in the hope that it will be useful,
010 * but WITHOUT ANY WARRANTY; without even the implied warranty of
011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
012 * GNU General Public License for more details.
013 *
014 * You should have received a copy of the GNU General Public License
015 * along with this program; if not, see <http://www.gnu.org/licenses>.
016 */
017
018package com.unboundid.scim2.server.utils;
019
020import com.fasterxml.jackson.databind.JsonNode;
021import com.fasterxml.jackson.databind.node.ObjectNode;
022import com.fasterxml.jackson.databind.node.TextNode;
023import com.unboundid.scim2.common.types.AttributeDefinition;
024import com.unboundid.scim2.common.Path;
025import com.unboundid.scim2.common.types.SchemaResource;
026import com.unboundid.scim2.common.exceptions.BadRequestException;
027import com.unboundid.scim2.common.exceptions.ScimException;
028import com.unboundid.scim2.common.filters.Filter;
029import com.unboundid.scim2.common.messages.PatchOperation;
030import com.unboundid.scim2.common.utils.Debug;
031import com.unboundid.scim2.common.utils.DebugType;
032import com.unboundid.scim2.common.utils.FilterEvaluator;
033import com.unboundid.scim2.common.utils.JsonUtils;
034import com.unboundid.scim2.common.utils.SchemaUtils;
035import com.unboundid.scim2.common.utils.StaticUtils;
036
037import java.net.URI;
038import java.util.Collection;
039import java.util.Collections;
040import java.util.HashSet;
041import java.util.Iterator;
042import java.util.LinkedHashSet;
043import java.util.LinkedList;
044import java.util.List;
045import java.util.Map;
046import java.util.Set;
047import java.util.logging.Level;
048
049/**
050 * Utility class used to validate and enforce the schema constraints of a
051 * Resource Type on JSON objects representing SCIM resources.
052 */
053public class SchemaChecker
054{
055  /**
056   * Schema checking results.
057   */
058  public static class Results
059  {
060    private final List<String> syntaxIssues = new LinkedList<String>();
061    private final List<String> mutabilityIssues = new LinkedList<String>();
062    private final List<String> pathIssues = new LinkedList<String>();
063    private final List<String> filterIssues = new LinkedList<String>();
064
065    void addFilterIssue(final String issue)
066    {
067      filterIssues.add(issue);
068    }
069
070    /**
071     * Retrieve any syntax issues found during schema checking.
072     *
073     * @return syntax issues found during schema checking.
074     */
075    public List<String> getSyntaxIssues()
076    {
077      return Collections.unmodifiableList(syntaxIssues);
078    }
079
080    /**
081     * Retrieve any mutability issues found during schema checking.
082     *
083     * @return mutability issues found during schema checking.
084     */
085    public List<String> getMutabilityIssues()
086    {
087      return Collections.unmodifiableList(mutabilityIssues);
088    }
089
090    /**
091     * Retrieve any path issues found during schema checking.
092     *
093     * @return path issues found during schema checking.
094     */
095    public List<String> getPathIssues()
096    {
097      return Collections.unmodifiableList(pathIssues);
098    }
099
100    /**
101     * Retrieve any filter issues found during schema checking.
102     *
103     * @return filter issues found during schema checking.
104     */
105    public List<String> getFilterIssues()
106    {
107      return Collections.unmodifiableList(filterIssues);
108    }
109
110    /**
111     * Throws an exception if there are schema validation errors.  The exception
112     * will contain all of the syntax errors, mutability errors or path issues
113     * (in that order of precedence).  The exception message will be the content
114     * of baseExceptionMessage followed by a space delimited list of all of the
115     * issues of the type (syntax, mutability, or path) being reported.
116     *
117     * @throws BadRequestException if issues are found during schema checking.
118     */
119    public void throwSchemaExceptions()
120      throws BadRequestException
121    {
122      if(syntaxIssues.size() > 0)
123      {
124        throw BadRequestException.invalidSyntax(getErrorString(syntaxIssues));
125      }
126
127      if(mutabilityIssues.size() > 0)
128      {
129        throw BadRequestException.mutability(getErrorString(mutabilityIssues));
130      }
131
132      if(pathIssues.size() > 0)
133      {
134        throw BadRequestException.invalidPath(getErrorString(pathIssues));
135      }
136
137      if(filterIssues.size() > 0)
138      {
139        throw BadRequestException.invalidFilter(getErrorString(filterIssues));
140      }
141    }
142
143    private String getErrorString(final List<String> issues)
144    {
145      if ((issues == null) || issues.isEmpty())
146      {
147        return null;
148      }
149
150      return StaticUtils.collectionToString(issues, ", ");
151    }
152  }
153
154  /**
155   * Enumeration that defines options affecting the way schema checking is
156   * performed. These options may be enabled and disabled before using the
157   * schema checker.
158   */
159  public enum Option
160  {
161    /**
162     * Relax SCIM 2 standard schema requirements by allowing core or extended
163     * attributes in the resource that are not defined by any schema in the
164     * resource type definition.
165     */
166    ALLOW_UNDEFINED_ATTRIBUTES,
167
168    /**
169     * Relax SCIM 2 standard schema requirements by allowing sub-attributes
170     * that are not defined by the definition of the parent attribute.
171     */
172    ALLOW_UNDEFINED_SUB_ATTRIBUTES;
173  }
174
175  private final ResourceTypeDefinition resourceType;
176  private final Collection<AttributeDefinition> commonAndCoreAttributes;
177  private final Set<Option> enabledOptions;
178
179  /**
180   * Create a new instance that may be used to validate and enforce schema
181   * constraints for a resource type.
182   *
183   * @param resourceType The resource type whose schema(s) to enforce.
184   */
185  public SchemaChecker(final ResourceTypeDefinition resourceType)
186  {
187    this.resourceType = resourceType;
188    this.commonAndCoreAttributes = new LinkedHashSet<AttributeDefinition>(
189        resourceType.getCoreSchema().getAttributes().size() + 4);
190    this.commonAndCoreAttributes.addAll(
191        SchemaUtils.COMMON_ATTRIBUTE_DEFINITIONS);
192    this.commonAndCoreAttributes.addAll(
193        resourceType.getCoreSchema().getAttributes());
194    this.enabledOptions = new HashSet<Option>();
195  }
196
197  /**
198   * Enable an option.
199   *
200   * @param option The option to enable.
201   */
202  public void enable(final Option option)
203  {
204    enabledOptions.add(option);
205  }
206
207  /**
208   * Disable an option.
209   *
210   * @param option The option to disable.
211   */
212  public void disable(final Option option)
213  {
214    enabledOptions.remove(option);
215  }
216
217  /**
218   * Check a new SCIM resource against the schema.
219   *
220   * The following checks will be performed:
221   * <ul>
222   *   <li>
223   *     All schema URIs in the schemas attribute are defined.
224   *   </li>
225   *   <li>
226   *     All required schema extensions are present.
227   *   </li>
228   *   <li>
229   *     All required attributes are present.
230   *   </li>
231   *   <li>
232   *     All attributes are defined in schema.
233   *   </li>
234   *   <li>
235   *     All attribute values match the types defined in schema.
236   *   </li>
237   *   <li>
238   *     All canonical type values match one of the values defined in the
239   *     schema.
240   *   </li>
241   *   <li>
242   *     No attributes with values are read-only.
243   *   </li>
244   * </ul>
245   *
246   * @param objectNode The SCIM resource that will be created. Any read-only
247   *                   attributes should be removed first using
248   *                   {@link #removeReadOnlyAttributes(ObjectNode)}.
249   * @return Schema checking results.
250   * @throws ScimException If an error occurred while checking the schema.
251   */
252  public Results checkCreate(final ObjectNode objectNode) throws ScimException
253  {
254    ObjectNode copyNode = objectNode.deepCopy();
255    Results results = new Results();
256    checkResource("", copyNode, results, null, false);
257    return results;
258  }
259
260  /**
261   * Check a set of modify patch operations against the schema. The current
262   * state of the SCIM resource may be provided to enable additional checks
263   * for attributes that are immutable or required.
264   *
265   * The following checks will be performed:
266   * <ul>
267   *   <li>
268   *     Undefined schema URIs are not added to the schemas attribute.
269   *   </li>
270   *   <li>
271   *     Required schema extensions are not removed.
272   *   </li>
273   *   <li>
274   *     Required attributes are not removed.
275   *   </li>
276   *   <li>
277   *     Undefined attributes are not added.
278   *   </li>
279   *   <li>
280   *     New attribute values match the types defined in the schema.
281   *   </li>
282   *   <li>
283   *     New canonical values match one of the values defined in the schema.
284   *   </li>
285   *   <li>
286   *     Read-only attribute are not modified.
287   *   </li>
288   * </ul>
289   *
290   * Additional checks if the current state of the SCIM resource is provided:
291   * <ul>
292   *   <li>
293   *     The last value from a required multi-valued attribute is not removed.
294   *   </li>
295   *   <li>
296   *     Immutable attribute values are not modified if they already have a
297   *     value.
298   *   </li>
299   * </ul>
300   *
301   * @param patchOperations The set of modify patch operations to check.
302   * @param currentObjectNode The current state of the SCIM resource or
303   *                          {@code null} if not available. Any read-only
304   *                          attributes should be removed first using
305   *                          {@link #removeReadOnlyAttributes(ObjectNode)}.
306   * @return Schema checking results.
307   * @throws ScimException If an error occurred while checking the schema.
308   */
309  public Results checkModify(final Iterable<PatchOperation> patchOperations,
310                             final ObjectNode currentObjectNode)
311      throws ScimException
312  {
313    ObjectNode copyCurrentNode =
314        currentObjectNode == null ? null : currentObjectNode.deepCopy();
315    ObjectNode appliedNode =
316        currentObjectNode == null ? null :
317            removeReadOnlyAttributes(currentObjectNode.deepCopy());
318    Results results = new Results();
319
320    int i = 0;
321    String prefix;
322    for(PatchOperation patchOp : patchOperations)
323    {
324      prefix = "Patch op[" + i + "]: ";
325      Path path = patchOp.getPath();
326      JsonNode value = patchOp.getJsonNode();
327      Filter valueFilter =
328          path == null ? null :
329              path.getElement(path.size() - 1).getValueFilter();
330      AttributeDefinition attribute = path == null ? null :
331          resourceType.getAttributeDefinition(path);
332      if(path != null && attribute == null)
333      {
334        // Can't find the attribute definition for attribute in path.
335        addMessageForUndefinedAttr(path, prefix, results.pathIssues);
336        continue;
337      }
338      if(valueFilter != null && attribute != null && !attribute.isMultiValued())
339      {
340        results.pathIssues.add(prefix +
341            "Attribute " + path.getElement(0)+ " in path " +
342            path.toString() + " must not have a value selection filter " +
343            "because it is not multi-valued");
344      }
345      if(valueFilter != null && attribute != null)
346      {
347        SchemaCheckFilterVisitor.checkValueFilter(
348            path.withoutFilters(), valueFilter, resourceType, this,
349            enabledOptions, results);
350      }
351      switch (patchOp.getOpType())
352      {
353        case REMOVE:
354          if(attribute == null)
355          {
356            continue;
357          }
358          checkAttributeMutability(prefix, null, path, attribute, results,
359              currentObjectNode, false, false, false);
360          if(valueFilter == null)
361          {
362            checkAttributeRequired(prefix, path, attribute, results);
363          }
364          break;
365        case REPLACE:
366          if(attribute == null)
367          {
368            checkPartialResource(prefix, (ObjectNode) value, results,
369                copyCurrentNode, true, false);
370          }
371          else
372          {
373            checkAttributeMutability(prefix, value, path, attribute, results,
374                currentObjectNode, true, false, false);
375            if(valueFilter != null)
376            {
377              checkAttributeValue(prefix, value, path, attribute, results,
378                  currentObjectNode, true, false);
379            }
380            else
381            {
382              checkAttributeValues(prefix, value, path, attribute, results,
383                  copyCurrentNode, true, false);
384            }
385          }
386          break;
387        case ADD:
388          if(attribute == null)
389          {
390            checkPartialResource(prefix, (ObjectNode) value, results,
391                copyCurrentNode, false, true);
392          }
393          else
394          {
395            checkAttributeMutability(prefix, value, path, attribute, results,
396                currentObjectNode, false, true, false);
397            if(valueFilter != null)
398            {
399              checkAttributeValue(prefix, value, path, attribute, results,
400                  currentObjectNode, false, true);
401            }
402            else
403            {
404              checkAttributeValues(prefix, value, path, attribute, results,
405                  copyCurrentNode, false, true);
406            }
407          }
408          break;
409      }
410
411      if(appliedNode != null)
412      {
413        // Apply the patch so we can later ensure these set of operations
414        // wont' be removing the all the values from a
415        // required multi-valued attribute.
416        try
417        {
418          patchOp.apply(appliedNode);
419        }
420        catch(BadRequestException e)
421        {
422          // No target exceptions are operational errors and not related
423          // to the schema. Just ignore.
424          if(!e.getScimError().getScimType().equals(
425              BadRequestException.NO_TARGET))
426          {
427            throw e;
428          }
429        }
430      }
431
432      i++;
433    }
434
435    if(appliedNode != null)
436    {
437      checkResource("Applying patch ops results in an invalid resource: ",
438          appliedNode, results, copyCurrentNode, false);
439    }
440
441    return results;
442  }
443
444  /**
445   * Check a replacement SCIM resource against the schema. The current
446   * state of the SCIM resource may be provided to enable additional checks
447   * for attributes that are immutable.
448   *
449   * The following checks will be performed:
450   * <ul>
451   *   <li>
452   *     All schema URIs in the schemas attribute are defined.
453   *   </li>
454   *   <li>
455   *     All required schema extensions are present.
456   *   </li>
457   *   <li>
458   *     All attributes are defined in schema.
459   *   </li>
460   *   <li>
461   *     All attribute values match the types defined in schema.
462   *   </li>
463   *   <li>
464   *     All canonical type values match one of the values defined in the
465   *     schema.
466   *   </li>
467   *   <li>
468   *     No attributes with values are read-only.
469   *   </li>
470   * </ul>
471   *
472   * Additional checks if the current state of the SCIM resource is provided:
473   * <ul>
474   *   <li>
475   *     Immutable attribute values are not replaced if they already have a
476   *     value.
477   *   </li>
478   * </ul>
479   *
480   * @param replacementObjectNode The replacement SCIM resource to check.
481   * @param currentObjectNode The current state of the SCIM resource or
482   *                          {@code null} if not available.
483   * @return Schema checking results.
484   * @throws ScimException If an error occurred while checking the schema.
485   */
486  public Results checkReplace(final ObjectNode replacementObjectNode,
487                              final ObjectNode currentObjectNode)
488      throws ScimException
489  {
490    ObjectNode copyReplacementNode = replacementObjectNode.deepCopy();
491    ObjectNode copyCurrentNode =
492        currentObjectNode == null ? null : currentObjectNode.deepCopy();
493    Results results = new Results();
494    checkResource("", copyReplacementNode, results, copyCurrentNode, true);
495    return results;
496  }
497
498  /**
499   * Remove any read-only attributes and/or sub-attributes that are present in
500   * the provided SCIM resource. This should be performed on new and
501   * replacement SCIM resources before schema checking since read-only
502   * attributes should be ignored by the service provider on create with POST
503   * and modify with PUT operations.
504   *
505   * @param objectNode The SCIM resource to remove read-only attributes from.
506   *                   This method will not alter the provided resource.
507   * @return A copy of the SCIM resource with the read-only attributes (if any)
508   *         removed.
509   */
510  public ObjectNode removeReadOnlyAttributes(final ObjectNode objectNode)
511  {
512    ObjectNode copyNode = objectNode.deepCopy();
513    for(SchemaResource schemaExtension :
514        resourceType.getSchemaExtensions().keySet())
515    {
516      JsonNode extension = copyNode.get(schemaExtension.getId());
517      if(extension != null && extension.isObject())
518      {
519        removeReadOnlyAttributes(schemaExtension.getAttributes(),
520            (ObjectNode) extension);
521      }
522    }
523    removeReadOnlyAttributes(commonAndCoreAttributes, copyNode);
524    return copyNode;
525  }
526
527
528
529  /**
530   * Check the provided filter against the schema.
531   *
532   * @param filter   The filter to check.
533   * @return Schema checking results.
534   * @throws ScimException If an error occurred while checking the schema.
535   */
536  public Results checkSearch(final Filter filter)
537      throws ScimException
538  {
539    Results results = new Results();
540    SchemaCheckFilterVisitor.checkFilter(
541        filter, resourceType, this, enabledOptions, results);
542    return results;
543  }
544
545
546
547  /**
548   * Generate an appropriate error message(s) for an undefined attribute, or
549   * no message if the enabled options allow for the undefined attribute.
550   *
551   * @param  path           The path referencing an undefined attribute.
552   * @param  messagePrefix  A prefix for the generated message, or empty string
553   *                        if no prefix is needed.
554   * @param  messages       The generated messages are to be added to this list.
555   */
556  void addMessageForUndefinedAttr(final Path path,
557                                  final String messagePrefix,
558                                  final List<String> messages)
559  {
560    if(path.size() > 1)
561    {
562      // This is a path to a sub-attribute. See if the parent attribute is
563      // defined.
564      if(resourceType.getAttributeDefinition(path.subPath(1)) == null)
565      {
566        // The parent attribute is also undefined.
567        if(!enabledOptions.contains(Option.ALLOW_UNDEFINED_ATTRIBUTES))
568        {
569          messages.add(messagePrefix +
570              "Attribute " + path.getElement(0)+ " in path " +
571              path.toString() + " is undefined");
572        }
573      }
574      else
575      {
576        // The parent attribute is defined but the sub-attribute is
577        // undefined.
578        if(!enabledOptions.contains(Option.ALLOW_UNDEFINED_SUB_ATTRIBUTES))
579        {
580          messages.add(messagePrefix +
581              "Sub-attribute " + path.getElement(1)+ " in path " +
582              path.toString() + " is undefined");
583        }
584      }
585    }
586    else if(!enabledOptions.contains(Option.ALLOW_UNDEFINED_ATTRIBUTES))
587    {
588      messages.add(messagePrefix +
589          "Attribute " + path.getElement(0)+ " in path " +
590          path.toString() + " is undefined");
591    }
592  }
593
594
595
596  /**
597   * Internal method to remove read-only attributes.
598   *
599   * @param attributes The collection of attribute definitions.
600   * @param objectNode The ObjectNode to remove from.
601   */
602  private void removeReadOnlyAttributes(
603      final Collection<AttributeDefinition> attributes,
604      final ObjectNode objectNode)
605  {
606    for(AttributeDefinition attribute : attributes)
607    {
608      if(attribute.getMutability() == AttributeDefinition.Mutability.READ_ONLY)
609      {
610        objectNode.remove(attribute.getName());
611        continue;
612      }
613      if(attribute.getSubAttributes() != null)
614      {
615        JsonNode node = objectNode.path(attribute.getName());
616        if (node.isObject())
617        {
618          removeReadOnlyAttributes(attribute.getSubAttributes(),
619              (ObjectNode) node);
620        } else if (node.isArray())
621        {
622          for (JsonNode value : node)
623          {
624            if (value.isObject())
625            {
626              removeReadOnlyAttributes(attribute.getSubAttributes(),
627                  (ObjectNode) value);
628            }
629          }
630        }
631      }
632    }
633  }
634
635  /**
636   * Check a partial resource that is part of the patch operation with no
637   * path.
638   *
639   * @param prefix The issue prefix.
640   * @param objectNode The partial resource.
641   * @param results The schema check results.
642   * @param currentObjectNode The current resource.
643   * @param isPartialReplace Whether this is a partial replace.
644   * @param isPartialAdd Whether this is a partial add.
645   * @throws ScimException If an error occurs.
646   */
647  private void checkPartialResource(final String prefix,
648                                    final ObjectNode objectNode,
649                                    final Results results,
650                                    final ObjectNode currentObjectNode,
651                                    final boolean isPartialReplace,
652                                    final boolean isPartialAdd)
653      throws ScimException
654  {
655
656    Iterator<Map.Entry<String, JsonNode>> i = objectNode.fields();
657    while(i.hasNext())
658    {
659      Map.Entry<String, JsonNode> field = i.next();
660      if(SchemaUtils.isUrn(field.getKey()))
661      {
662        if(!field.getValue().isObject())
663        {
664          // Bail if the extension namespace is not valid
665          results.syntaxIssues.add(prefix + "Extended attributes namespace " +
666              field.getKey() + " must be a JSON object");
667        }
668        else
669        {
670          boolean found = false;
671          for (SchemaResource schemaExtension :
672              resourceType.getSchemaExtensions().keySet())
673          {
674            if (schemaExtension.getId().equals(field.getKey()))
675            {
676              checkObjectNode(prefix, Path.root(field.getKey()),
677                  schemaExtension.getAttributes(),
678                  (ObjectNode) field.getValue(), results, currentObjectNode,
679                  isPartialReplace, isPartialAdd, false);
680              found = true;
681              break;
682            }
683          }
684          if(!found &&
685              !enabledOptions.contains(Option.ALLOW_UNDEFINED_ATTRIBUTES))
686          {
687            results.syntaxIssues.add(prefix + "Undefined extended attributes " +
688                "namespace " + field);
689          }
690        }
691        i.remove();
692      }
693    }
694
695    // Check common and core schema
696    checkObjectNode(prefix, Path.root(), commonAndCoreAttributes,
697        objectNode, results, currentObjectNode,
698        isPartialReplace, isPartialAdd, false);
699  }
700
701  /**
702   * Internal method to check a SCIM resource.
703   *
704   * @param prefix The issue prefix.
705   * @param objectNode The partial resource.
706   * @param results The schema check results.
707   * @param currentObjectNode The current resource.
708   * @param isReplace Whether this is a replace.
709   * @throws ScimException If an error occurs.
710   */
711  private void checkResource(final String prefix,
712                             final ObjectNode objectNode,
713                             final Results results,
714                             final ObjectNode currentObjectNode,
715                             final boolean isReplace)
716      throws ScimException
717  {
718    // Iterate through the schemas
719    JsonNode schemas = objectNode.get(
720        SchemaUtils.SCHEMAS_ATTRIBUTE_DEFINITION.getName());
721    if(schemas != null && schemas.isArray())
722    {
723      boolean coreFound = false;
724      for (JsonNode schema : schemas)
725      {
726        if (!schema.isTextual())
727        {
728          // Go to the next one if the schema URI is not valid. We will report
729          // this issue later when we check the values for the schemas
730          // attribute.
731          continue;
732        }
733
734        // Get the extension namespace object node.
735        JsonNode extensionNode = objectNode.remove(schema.textValue());
736        if (extensionNode == null)
737        {
738          // Extension listed in schemas but no namespace in resource. Treat it
739          // as an empty namesapce to check for required attributes.
740          extensionNode = JsonUtils.getJsonNodeFactory().objectNode();
741        }
742        if (!extensionNode.isObject())
743        {
744          // Go to the next one if the extension namespace is not valid
745          results.syntaxIssues.add(prefix + "Extended attributes namespace " +
746              schema.textValue() + " must be a JSON object");
747          continue;
748        }
749
750        // Find the schema definition.
751        Map.Entry<SchemaResource, Boolean> extensionDefinition = null;
752        if (schema.textValue().equals(resourceType.getCoreSchema().getId()))
753        {
754          // Skip the core schema.
755          coreFound = true;
756          continue;
757        } else
758        {
759          for (Map.Entry<SchemaResource, Boolean> schemaExtension :
760              resourceType.getSchemaExtensions().entrySet())
761          {
762            if (schema.textValue().equals(schemaExtension.getKey().getId()))
763            {
764              extensionDefinition = schemaExtension;
765              break;
766            }
767          }
768        }
769
770        if (extensionDefinition == null)
771        {
772          // Bail if we can't find the schema definition. We will report this
773          // issue later when we check the values for the schemas attribute.
774          continue;
775        }
776
777        checkObjectNode(prefix, Path.root(schema.textValue()),
778            extensionDefinition.getKey().getAttributes(),
779            (ObjectNode) extensionNode, results, currentObjectNode,
780                        isReplace, false, isReplace);
781      }
782
783      if (!coreFound)
784      {
785        // Make sure core schemas was included.
786        results.syntaxIssues.add(prefix + "Value for attribute schemas must " +
787            " contain schema URI " + resourceType.getCoreSchema().getId() +
788            " because it is the core schema for this resource type");
789      }
790
791      // Make sure all required extension schemas were included.
792      for (Map.Entry<SchemaResource, Boolean> schemaExtension :
793          resourceType.getSchemaExtensions().entrySet())
794      {
795        if (schemaExtension.getValue())
796        {
797          boolean found = false;
798          for (JsonNode schema : schemas)
799          {
800            if (schema.textValue().equals(schemaExtension.getKey().getId()))
801            {
802              found = true;
803              break;
804            }
805          }
806          if (!found)
807          {
808            results.syntaxIssues.add(prefix + "Value for attribute schemas " +
809                "must contain schema URI " + schemaExtension.getKey().getId() +
810                " because it is a required schema extension for this " +
811                "resource type");
812          }
813        }
814      }
815    }
816
817    // All defined schema extensions should be removed.
818    // Remove any additional extended attribute namespaces not included in
819    // the schemas attribute.
820    Iterator<Map.Entry<String, JsonNode>> i = objectNode.fields();
821    while(i.hasNext())
822    {
823      String fieldName = i.next().getKey();
824      if(SchemaUtils.isUrn(fieldName))
825      {
826        results.syntaxIssues.add(prefix + "Extended attributes namespace "
827            + fieldName + " must be included in the schemas attribute");
828        i.remove();
829      }
830    }
831
832    // Check common and core schema
833    checkObjectNode(prefix, Path.root(), commonAndCoreAttributes,
834        objectNode, results, currentObjectNode,
835                    isReplace, false, isReplace);
836  }
837
838  /**
839   * Check the attribute to see if it violated any mutability constraints.
840   *
841   * @param prefix The issue prefix.
842   * @param node The attribute value.
843   * @param path The attribute path.
844   * @param attribute The attribute definition.
845   * @param results The schema check results.
846   * @param currentObjectNode The current resource.
847   * @param isPartialReplace Whether this is a partial replace.
848   * @param isPartialAdd Whether this is a partial add.
849   * @param isReplace Whether this is a replace.
850   * @throws ScimException If an error occurs.
851   */
852  private void checkAttributeMutability(final String prefix,
853                                        final JsonNode node,
854                                        final Path path,
855                                        final AttributeDefinition attribute,
856                                        final Results results,
857                                        final ObjectNode currentObjectNode,
858                                        final boolean isPartialReplace,
859                                        final boolean isPartialAdd,
860                                        final boolean isReplace)
861      throws ScimException
862  {
863    if(attribute.getMutability() ==
864        AttributeDefinition.Mutability.READ_ONLY)
865    {
866      results.mutabilityIssues.add(prefix + "Attribute " + path +
867          " is read-only");
868    }
869    if(attribute.getMutability() ==
870        AttributeDefinition.Mutability.IMMUTABLE )
871    {
872      if(node == null)
873      {
874        results.mutabilityIssues.add(prefix + "Attribute " + path +
875            " is immutable and value(s) may not be removed");
876      }
877      if(isPartialReplace && !isReplace)
878      {
879        results.mutabilityIssues.add(prefix + "Attribute " + path +
880            " is immutable and value(s) may not be replaced");
881      }
882      else if(isPartialAdd && currentObjectNode != null &&
883          JsonUtils.pathExists(path, currentObjectNode))
884      {
885        results.mutabilityIssues.add(prefix + "Attribute " + path +
886            " is immutable and value(s) may not be added");
887      }
888      else if(currentObjectNode != null)
889      {
890        List<JsonNode> currentValues =
891            JsonUtils.findMatchingPaths(path, currentObjectNode);
892        if(currentValues.size() > 1 ||
893            (currentValues.size() == 1 && !currentValues.get(0).equals(node)))
894        {
895          results.mutabilityIssues.add(prefix + "Attribute " + path +
896              " is immutable and it already has a value");
897        }
898      }
899    }
900
901    Filter valueFilter = path.getElement(path.size() - 1).getValueFilter();
902    if(attribute.equals(SchemaUtils.SCHEMAS_ATTRIBUTE_DEFINITION) &&
903        valueFilter != null)
904    {
905      // Make sure the core schema and/or required schemas extensions are
906      // not removed.
907      if (FilterEvaluator.evaluate(valueFilter,
908          TextNode.valueOf(resourceType.getCoreSchema().getId())))
909      {
910        results.syntaxIssues.add(prefix + "Attribute value(s) " + path +
911            " may not be removed or replaced because the core schema " +
912            resourceType.getCoreSchema().getId() +
913            " is required for this resource type");
914      }
915      for (Map.Entry<SchemaResource, Boolean> schemaExtension :
916          resourceType.getSchemaExtensions().entrySet())
917      {
918        if (schemaExtension.getValue() &&
919            FilterEvaluator.evaluate(valueFilter,
920                TextNode.valueOf(schemaExtension.getKey().getId())))
921        {
922          results.syntaxIssues.add(prefix + "Attribute value(s) " +
923              path + " may not be removed or replaced because the schema " +
924              "extension " + schemaExtension.getKey().getId() +
925              " is required for this resource type");
926        }
927      }
928    }
929  }
930
931  /**
932   * Check the attribute to see if it violated any requirement constraints.
933   *
934   * @param prefix The issue prefix.
935   * @param path The attribute path.
936   * @param attribute The attribute definition.
937   * @param results The schema check results.
938   */
939  private void checkAttributeRequired(final String prefix,
940                                      final Path path,
941                                      final AttributeDefinition attribute,
942                                      final Results results)
943  {
944    // Check required attributes are all present.
945    if(attribute.isRequired())
946    {
947      results.syntaxIssues.add(prefix + "Attribute " + path +
948          " is required and must have a value");
949    }
950  }
951
952  /**
953   * Check the attribute values to see if it has the right type.
954   *
955   * @param prefix The issue prefix.
956   * @param node The attribute value.
957   * @param path The attribute path.
958   * @param attribute The attribute definition.
959   * @param results The schema check results.
960   * @param currentObjectNode The current resource.
961   * @param isPartialReplace Whether this is a partial replace.
962   * @param isPartialAdd Whether this is a partial add.
963   * @throws ScimException If an error occurs.
964   */
965  private void checkAttributeValues(final String prefix,
966                                    final JsonNode node,
967                                    final Path path,
968                                    final AttributeDefinition attribute,
969                                    final Results results,
970                                    final ObjectNode currentObjectNode,
971                                    final boolean isPartialReplace,
972                                    final boolean isPartialAdd)
973      throws ScimException
974  {
975    if(attribute.isMultiValued() && !node.isArray())
976    {
977      results.syntaxIssues.add(prefix + "Value for multi-valued attribute " +
978          path + " must be a JSON array");
979      return;
980    }
981    if(!attribute.isMultiValued() && node.isArray())
982    {
983      results.syntaxIssues.add(prefix + "Value for single-valued attribute " +
984          path + " must not be a JSON array");
985      return;
986    }
987
988    if(node.isArray())
989    {
990      int i = 0;
991      for (JsonNode value : node)
992      {
993        // Use a special notation attr[index] to refer to a value of an JSON
994        // array.
995        if(path.isRoot())
996        {
997          throw new NullPointerException(
998              "Path should always point to an attribute");
999        }
1000        Path parentPath = path.subPath(path.size() - 1);
1001        Path valuePath = parentPath.attribute(
1002            path.getElement(path.size() - 1).getAttribute() + "[" + i + "]");
1003        checkAttributeValue(prefix, value, valuePath, attribute, results,
1004            currentObjectNode, isPartialReplace, isPartialAdd);
1005        i++;
1006      }
1007    }
1008    else
1009    {
1010      checkAttributeValue(prefix, node, path, attribute, results,
1011          currentObjectNode, isPartialReplace, isPartialAdd);
1012    }
1013  }
1014
1015  /**
1016   * Check an attribute value to see if it has the right type.
1017   *
1018   * @param prefix The issue prefix.
1019   * @param node The attribute value.
1020   * @param path The attribute path.
1021   * @param attribute The attribute definition.
1022   * @param results The schema check results.
1023   * @param currentObjectNode The current resource.
1024   * @param isPartialReplace Whether this is a partial replace.
1025   * @param isPartialAdd Whether this is a partial add.
1026   * @throws ScimException If an error occurs.
1027   */
1028  private void checkAttributeValue(final String prefix,
1029                                   final JsonNode node,
1030                                   final Path path,
1031                                   final AttributeDefinition attribute,
1032                                   final Results results,
1033                                   final ObjectNode currentObjectNode,
1034                                   final boolean isPartialReplace,
1035                                   final boolean isPartialAdd)
1036      throws ScimException
1037  {
1038    if(node.isNull())
1039    {
1040      return;
1041    }
1042
1043    // Check the node type.
1044    switch(attribute.getType())
1045    {
1046      case STRING:
1047      case DATETIME:
1048      case REFERENCE:
1049        if (!node.isTextual())
1050        {
1051          results.syntaxIssues.add(prefix + "Value for attribute " + path +
1052              " must be a JSON string");
1053          return;
1054        }
1055        break;
1056      case BOOLEAN:
1057        if (!node.isBoolean())
1058        {
1059          results.syntaxIssues.add(prefix + "Value for attribute " + path +
1060              " must be a JSON boolean");
1061          return;
1062        }
1063        break;
1064      case DECIMAL:
1065      case INTEGER:
1066        if (!node.isNumber())
1067        {
1068          results.syntaxIssues.add(prefix + "Value for attribute " + path +
1069              " must be a JSON number");
1070          return;
1071        }
1072        break;
1073      case COMPLEX:
1074        if (!node.isObject())
1075        {
1076          results.syntaxIssues.add(prefix + "Value for attribute " + path +
1077              " must be a JSON object");
1078          return;
1079        }
1080        break;
1081      case BINARY:
1082        if (!node.isTextual() && !node.isBinary())
1083        {
1084          results.syntaxIssues.add(prefix + "Value for attribute " + path +
1085              " must be a JSON string");
1086          return;
1087        }
1088        break;
1089      default:
1090        throw new RuntimeException(
1091            "Unexpected attribute type " + attribute.getType());
1092    }
1093
1094    // If the node type checks out, check the actual value.
1095    switch(attribute.getType())
1096    {
1097      case DATETIME:
1098        try
1099        {
1100          JsonUtils.nodeToDateValue(node);
1101        }
1102        catch (Exception e)
1103        {
1104          Debug.debug(Level.INFO, DebugType.EXCEPTION,
1105              "Invalid xsd:dateTime string during schema checking", e);
1106          results.syntaxIssues.add(prefix + "Value for attribute " + path +
1107              " is not a valid xsd:dateTime formatted string");
1108        }
1109        break;
1110      case BINARY:
1111        try
1112        {
1113          node.binaryValue();
1114        }
1115        catch (Exception e)
1116        {
1117          Debug.debug(Level.INFO, DebugType.EXCEPTION,
1118              "Invalid base64 string during schema checking", e);
1119          results.syntaxIssues.add(prefix + "Value for attribute " + path +
1120              " is not a valid base64 encoded string");
1121        }
1122        break;
1123      case REFERENCE:
1124        try
1125        {
1126          new URI(node.textValue());
1127        }
1128        catch (Exception e)
1129        {
1130          Debug.debug(Level.INFO, DebugType.EXCEPTION,
1131              "Invalid URI string during schema checking", e);
1132          results.syntaxIssues.add(prefix + "Value for attribute " + path +
1133              " is not a valid URI string");
1134        }
1135        break;
1136      case INTEGER:
1137        if(!node.isIntegralNumber())
1138        {
1139          results.syntaxIssues.add(prefix + "Value for attribute " + path +
1140              " is not an integral number");
1141        }
1142        break;
1143      case COMPLEX:
1144        checkObjectNode(prefix, path, attribute.getSubAttributes(),
1145            (ObjectNode) node, results, currentObjectNode,
1146            isPartialReplace, isPartialAdd, false);
1147        break;
1148      case STRING:
1149        // Check for canonical values
1150        if (attribute.getCanonicalValues() != null)
1151        {
1152          boolean found = false;
1153          for (String canonicalValue : attribute.getCanonicalValues())
1154          {
1155            if (attribute.isCaseExact() ?
1156                canonicalValue.equals(node.textValue()) :
1157                StaticUtils.toLowerCase(canonicalValue).equals(
1158                    StaticUtils.toLowerCase(node.textValue())))
1159            {
1160              found = true;
1161              break;
1162            }
1163          }
1164          if (!found)
1165          {
1166            results.syntaxIssues.add(prefix + "Value " + node.textValue() +
1167                " is not valid for attribute " + path + " because it " +
1168                "is not one of the canonical types: " +
1169                StaticUtils.collectionToString(
1170                    attribute.getCanonicalValues(), ", "));
1171          }
1172        }
1173    }
1174
1175    // Special checking of the schemas attribute to ensure that
1176    // no undefined schemas are listed.
1177    if (attribute.equals(SchemaUtils.SCHEMAS_ATTRIBUTE_DEFINITION) &&
1178        path.size() == 1)
1179    {
1180      boolean found = false;
1181      for (SchemaResource schemaExtension :
1182          resourceType.getSchemaExtensions().keySet())
1183      {
1184        if (node.textValue().equals(schemaExtension.getId()))
1185        {
1186          found = true;
1187          break;
1188        }
1189      }
1190      if(!found)
1191      {
1192        found = node.textValue().equals(resourceType.getCoreSchema().getId());
1193      }
1194      if(!found && !enabledOptions.contains(Option.ALLOW_UNDEFINED_ATTRIBUTES))
1195      {
1196        results.syntaxIssues.add(prefix + "Schema URI " + node.textValue() +
1197            " is not a valid value for attribute " + path + " because it is " +
1198            "undefined as a core or schema extension for this resource type");
1199      }
1200    }
1201  }
1202
1203  /**
1204   * Check an ObjectNode containing the core attributes or extended attributes.
1205   *
1206   * @param prefix The issue prefix.
1207   * @param parentPath The path of the parent node.
1208   * @param attributes The attribute definitions.
1209   * @param objectNode The ObjectNode to check.
1210   * @param results The schema check results.
1211   * @param currentObjectNode The current resource.
1212   * @param isPartialReplace Whether this is a partial replace.
1213   * @param isPartialAdd Whether this is a partial add.
1214   * @param isReplace Whether this is a replace.
1215   * @throws ScimException If an error occurs.
1216   */
1217  private void checkObjectNode(
1218      final String prefix,
1219      final Path parentPath,
1220      final Collection<AttributeDefinition> attributes,
1221      final ObjectNode objectNode,
1222      final Results results,
1223      final ObjectNode currentObjectNode,
1224      final boolean isPartialReplace,
1225      final boolean isPartialAdd,
1226      final boolean isReplace) throws ScimException
1227  {
1228    if(attributes == null)
1229    {
1230      return;
1231    }
1232
1233    for(AttributeDefinition attribute : attributes)
1234    {
1235      JsonNode node = objectNode.remove(attribute.getName());
1236      Path path = parentPath.attribute((attribute.getName()));
1237
1238      if(node == null || node.isNull() || (node.isArray() && node.size() == 0))
1239      {
1240        // From SCIM's perspective, these are the same thing.
1241        if (!isPartialAdd && !isPartialReplace)
1242        {
1243          checkAttributeRequired(prefix, path, attribute, results);
1244        }
1245      }
1246      if(node != null)
1247      {
1248        // Additional checks for when the field is present
1249        checkAttributeMutability(prefix, node, path, attribute, results,
1250            currentObjectNode, isPartialReplace, isPartialAdd, isReplace);
1251        checkAttributeValues(prefix, node, path, attribute, results,
1252            currentObjectNode, isPartialReplace, isPartialAdd);
1253      }
1254    }
1255
1256    // All defined attributes should be removed. Remove any additional
1257    // undefined attributes.
1258    Iterator<Map.Entry<String, JsonNode>> i = objectNode.fields();
1259    while(i.hasNext())
1260    {
1261      String undefinedAttribute = i.next().getKey();
1262      if(parentPath.size() == 0)
1263      {
1264        if(!enabledOptions.contains(Option.ALLOW_UNDEFINED_ATTRIBUTES))
1265        {
1266          results.syntaxIssues.add(prefix + "Core attribute " +
1267              undefinedAttribute + " is undefined for schema " +
1268              resourceType.getCoreSchema().getId());
1269        }
1270      }
1271      else if(parentPath.isRoot() &&
1272          parentPath.getSchemaUrn() != null)
1273      {
1274        if(!enabledOptions.contains(Option.ALLOW_UNDEFINED_ATTRIBUTES))
1275        {
1276          results.syntaxIssues.add(prefix + "Extended attribute " +
1277              undefinedAttribute + " is undefined for schema " +
1278              parentPath.getSchemaUrn());
1279        }
1280      }
1281      else
1282      {
1283        if(!enabledOptions.contains(Option.ALLOW_UNDEFINED_SUB_ATTRIBUTES))
1284        {
1285          results.syntaxIssues.add(prefix + "Sub-attribute " +
1286              undefinedAttribute + " is undefined for attribute " + parentPath);
1287        }
1288      }
1289      i.remove();
1290    }
1291  }
1292}