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