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