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