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}