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