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