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