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