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