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