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