Mercurial > p > roundup > code
comparison roundup/rest.py @ 5630:07abc8d36940
Add etag support to rest interface to prevent multiple users from
overwriting other users changes.
All GET requests for an object (issue, user, keyword etc.) or a
property of an object (e.g the title of an issue) return the etag for
the object in the ETag header as well as the @etag field in the
returned object.
All requests that change existing objects (DELETE, PUT or PATCH)
require:
1 A request include an ETag header with the etag value retrieved
for the object.
2 A submits a form that includes the field @etag that must have
the value retrieved for the object.
If an etag is not supplied by one of these methods, or any supplied
etag does not match the etag calculated at the time the DELETE, PUT or
PATCH request is made, HTTP error 412 (Precondition Failed) is
returned and no change is made. At that time the client code should
retrieve the object again, reconcile the changes and can try to send a
new update.
The etag is the md5 hash of the representation (repr()) of the object
retrieved from the database.
| author | John Rouillard <rouilj@ieee.org> |
|---|---|
| date | Fri, 01 Mar 2019 22:57:07 -0500 |
| parents | 2a7d23a098ca |
| children | a5c890d308c3 |
comparison
equal
deleted
inserted
replaced
| 5624:b3618882f906 | 5630:07abc8d36940 |
|---|---|
| 22 from roundup import hyperdb | 22 from roundup import hyperdb |
| 23 from roundup import date | 23 from roundup import date |
| 24 from roundup import actions | 24 from roundup import actions |
| 25 from roundup.exceptions import * | 25 from roundup.exceptions import * |
| 26 from roundup.cgi.exceptions import * | 26 from roundup.cgi.exceptions import * |
| 27 | |
| 28 from hashlib import md5 | |
| 27 | 29 |
| 28 # Py3 compatible basestring | 30 # Py3 compatible basestring |
| 29 try: | 31 try: |
| 30 basestring | 32 basestring |
| 31 except NameError: | 33 except NameError: |
| 57 code = 405 | 59 code = 405 |
| 58 data = msg | 60 data = msg |
| 59 except ValueError as msg: | 61 except ValueError as msg: |
| 60 code = 409 | 62 code = 409 |
| 61 data = msg | 63 data = msg |
| 64 except PreconditionFailed as msg: | |
| 65 code = 412 | |
| 66 data = msg | |
| 62 except NotImplementedError: | 67 except NotImplementedError: |
| 63 code = 402 # nothing to pay, just a mark for debugging purpose | 68 code = 402 # nothing to pay, just a mark for debugging purpose |
| 64 data = 'Method under development' | 69 data = 'Method under development' |
| 65 except: | 70 except: |
| 66 exc, val, tb = sys.exc_info() | 71 exc, val, tb = sys.exc_info() |
| 89 'data': data | 94 'data': data |
| 90 } | 95 } |
| 91 return result | 96 return result |
| 92 return format_object | 97 return format_object |
| 93 | 98 |
| 99 def calculate_etag (node, classname="Missing", id="0"): | |
| 100 '''given a hyperdb node generate a hashed representation of it to be | |
| 101 used as an etag. | |
| 102 | |
| 103 This code needs a __repr__ function in the Password class. This | |
| 104 replaces the repr(items) which would be: | |
| 105 | |
| 106 <roundup.password.Password instance at 0x7f3442406170> | |
| 107 | |
| 108 with the string representation: | |
| 109 | |
| 110 {PBKDF2}10000$k4d74EDgxlbH...A | |
| 111 | |
| 112 This makes the representation repeatable as the location of the | |
| 113 password instance is not static and we need a constant value to | |
| 114 calculate the etag. | |
| 115 | |
| 116 Note that repr() is chosen for the node rather than str() since | |
| 117 repr is meant to be an unambiguous representation. | |
| 118 | |
| 119 classname and id are used for logging only. | |
| 120 ''' | |
| 121 | |
| 122 items = node.items(protected=True) # include every item | |
| 123 etag = md5(repr(items)).hexdigest() | |
| 124 logger.debug("object=%s%s; tag=%s; repr=%s", classname, id, | |
| 125 etag, repr(node.items(protected=True))) | |
| 126 return etag | |
| 127 | |
| 128 def check_etag (node, etags, classname="Missing", id="0"): | |
| 129 '''Take a list of etags and compare to the etag for the given node. | |
| 130 | |
| 131 Iterate over all supplied etags, | |
| 132 If a tag fails to match, return False. | |
| 133 If at least one etag matches, return True. | |
| 134 If all etags are None, return False. | |
| 135 | |
| 136 ''' | |
| 137 have_etag_match=False | |
| 138 | |
| 139 node_etag = calculate_etag(node, classname, id) | |
| 140 | |
| 141 for etag in etags: | |
| 142 if etag != None: | |
| 143 if etag != node_etag: | |
| 144 return False | |
| 145 have_etag_match=True | |
| 146 | |
| 147 if have_etag_match: | |
| 148 return True | |
| 149 else: | |
| 150 return False | |
| 151 | |
| 152 def obtain_etags(headers,input): | |
| 153 '''Get ETags value from headers or payload data''' | |
| 154 etags = [] | |
| 155 if '@etag' in input: | |
| 156 etags.append(input['@etag'].value); | |
| 157 etags.append(headers.getheader("ETag", None)) | |
| 158 return etags | |
| 94 | 159 |
| 95 def parse_accept_header(accept): | 160 def parse_accept_header(accept): |
| 96 """ | 161 """ |
| 97 Parse the Accept header *accept*, returning a list with 3-tuples of | 162 Parse the Accept header *accept*, returning a list with 3-tuples of |
| 98 [(str(media_type), dict(params), float(q_value)),] ordered by q values. | 163 [(str(media_type), dict(params), float(q_value)),] ordered by q values. |
| 482 raise Unauthorised( | 547 raise Unauthorised( |
| 483 'Permission to view %s%s denied' % (class_name, item_id) | 548 'Permission to view %s%s denied' % (class_name, item_id) |
| 484 ) | 549 ) |
| 485 | 550 |
| 486 class_obj = self.db.getclass(class_name) | 551 class_obj = self.db.getclass(class_name) |
| 552 node = class_obj.getnode(item_id) | |
| 553 etag = calculate_etag(node, class_name, item_id) | |
| 487 props = None | 554 props = None |
| 488 for form_field in input.value: | 555 for form_field in input.value: |
| 489 key = form_field.name | 556 key = form_field.name |
| 490 value = form_field.value | 557 value = form_field.value |
| 491 if key == "fields": | 558 if key == "fields": |
| 494 if props is None: | 561 if props is None: |
| 495 props = list(sorted(class_obj.properties.keys())) | 562 props = list(sorted(class_obj.properties.keys())) |
| 496 | 563 |
| 497 try: | 564 try: |
| 498 result = [ | 565 result = [ |
| 499 (prop_name, class_obj.get(item_id, prop_name)) | 566 (prop_name, node.__getattr__(prop_name)) |
| 500 for prop_name in props | 567 for prop_name in props |
| 501 if self.db.security.hasPermission( | 568 if self.db.security.hasPermission( |
| 502 'View', self.db.getuid(), class_name, prop_name, | 569 'View', self.db.getuid(), class_name, prop_name, |
| 503 item_id ) | 570 item_id ) |
| 504 ] | 571 ] |
| 506 raise UsageError("%s field not valid" % msg) | 573 raise UsageError("%s field not valid" % msg) |
| 507 result = { | 574 result = { |
| 508 'id': item_id, | 575 'id': item_id, |
| 509 'type': class_name, | 576 'type': class_name, |
| 510 'link': '%s/%s/%s' % (self.data_path, class_name, item_id), | 577 'link': '%s/%s/%s' % (self.data_path, class_name, item_id), |
| 511 'attributes': dict(result) | 578 'attributes': dict(result), |
| 579 '@etag': etag | |
| 512 } | 580 } |
| 513 | 581 |
| 582 self.client.setHeader("ETag", '"%s"'%etag) | |
| 514 return 200, result | 583 return 200, result |
| 515 | 584 |
| 516 @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'GET') | 585 @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'GET') |
| 517 @_data_decorator | 586 @_data_decorator |
| 518 def get_attribute(self, class_name, item_id, attr_name, input): | 587 def get_attribute(self, class_name, item_id, attr_name, input): |
| 544 'Permission to view %s%s %s denied' % | 613 'Permission to view %s%s %s denied' % |
| 545 (class_name, item_id, attr_name) | 614 (class_name, item_id, attr_name) |
| 546 ) | 615 ) |
| 547 | 616 |
| 548 class_obj = self.db.getclass(class_name) | 617 class_obj = self.db.getclass(class_name) |
| 549 data = class_obj.get(item_id, attr_name) | 618 node = class_obj.getnode(item_id) |
| 619 etag = calculate_etag(node, class_name, item_id) | |
| 620 data = node.__getattr__(attr_name) | |
| 550 result = { | 621 result = { |
| 551 'id': item_id, | 622 'id': item_id, |
| 552 'type': type(data), | 623 'type': type(data), |
| 553 'link': "%s/%s/%s/%s" % | 624 'link': "%s/%s/%s/%s" % |
| 554 (self.data_path, class_name, item_id, attr_name), | 625 (self.data_path, class_name, item_id, attr_name), |
| 555 'data': data | 626 'data': data, |
| 627 '@etag': etag | |
| 556 } | 628 } |
| 557 | 629 |
| 630 self.client.setHeader("ETag", '"%s"'%etag ) | |
| 558 return 200, result | 631 return 200, result |
| 559 | 632 |
| 560 @Routing.route("/data/<:class_name>", 'POST') | 633 @Routing.route("/data/<:class_name>", 'POST') |
| 561 @_data_decorator | 634 @_data_decorator |
| 562 def post_collection(self, class_name, input): | 635 def post_collection(self, class_name, input): |
| 653 raise Unauthorised( | 726 raise Unauthorised( |
| 654 'Permission to edit %s of %s%s denied' % | 727 'Permission to edit %s of %s%s denied' % |
| 655 (p, class_name, item_id) | 728 (p, class_name, item_id) |
| 656 ) | 729 ) |
| 657 try: | 730 try: |
| 731 if not check_etag(class_obj.getnode(item_id), | |
| 732 obtain_etags(self.client.request.headers, input), | |
| 733 class_name, | |
| 734 item_id): | |
| 735 raise PreconditionFailed("Etag is missing or does not match." | |
| 736 "Retreive asset and retry modification if valid.") | |
| 658 result = class_obj.set(item_id, **props) | 737 result = class_obj.set(item_id, **props) |
| 659 self.db.commit() | 738 self.db.commit() |
| 660 except (TypeError, IndexError, ValueError) as message: | 739 except (TypeError, IndexError, ValueError) as message: |
| 661 raise ValueError(message) | 740 raise ValueError(message) |
| 662 | 741 |
| 695 ): | 774 ): |
| 696 raise Unauthorised( | 775 raise Unauthorised( |
| 697 'Permission to edit %s%s %s denied' % | 776 'Permission to edit %s%s %s denied' % |
| 698 (class_name, item_id, attr_name) | 777 (class_name, item_id, attr_name) |
| 699 ) | 778 ) |
| 700 | |
| 701 class_obj = self.db.getclass(class_name) | 779 class_obj = self.db.getclass(class_name) |
| 702 props = { | 780 props = { |
| 703 attr_name: self.prop_from_arg( | 781 attr_name: self.prop_from_arg( |
| 704 class_obj, attr_name, input['data'].value, item_id | 782 class_obj, attr_name, input['data'].value, item_id |
| 705 ) | 783 ) |
| 706 } | 784 } |
| 707 | 785 |
| 708 try: | 786 try: |
| 787 if not check_etag(class_obj.getnode(item_id), | |
| 788 obtain_etags(self.client.request.headers, input), | |
| 789 class_name, item_id): | |
| 790 raise PreconditionFailed("Etag is missing or does not match." | |
| 791 "Retreive asset and retry modification if valid.") | |
| 709 result = class_obj.set(item_id, **props) | 792 result = class_obj.set(item_id, **props) |
| 710 self.db.commit() | 793 self.db.commit() |
| 711 except (TypeError, IndexError, ValueError) as message: | 794 except (TypeError, IndexError, ValueError) as message: |
| 712 raise ValueError(message) | 795 raise ValueError(message) |
| 713 | 796 |
| 789 ): | 872 ): |
| 790 raise Unauthorised( | 873 raise Unauthorised( |
| 791 'Permission to retire %s %s denied' % (class_name, item_id) | 874 'Permission to retire %s %s denied' % (class_name, item_id) |
| 792 ) | 875 ) |
| 793 | 876 |
| 877 if not check_etag(class_obj.getnode(item_id), | |
| 878 obtain_etags(self.client.request.headers, input), | |
| 879 class_name, | |
| 880 item_id): | |
| 881 raise PreconditionFailed("Etag is missing or does not match." | |
| 882 "Retreive asset and retry modification if valid.") | |
| 883 | |
| 794 class_obj.retire (item_id) | 884 class_obj.retire (item_id) |
| 795 self.db.commit() | 885 self.db.commit() |
| 796 result = { | 886 result = { |
| 797 'status': 'ok' | 887 'status': 'ok' |
| 798 } | 888 } |
| 832 props[attr_name] = [] | 922 props[attr_name] = [] |
| 833 else: | 923 else: |
| 834 props[attr_name] = None | 924 props[attr_name] = None |
| 835 | 925 |
| 836 try: | 926 try: |
| 927 if not check_etag(class_obj.getnode(item_id), | |
| 928 obtain_etags(self.client.request.headers, input), | |
| 929 class_name, | |
| 930 item_id): | |
| 931 raise PreconditionFailed("Etag is missing or does not match." | |
| 932 "Retreive asset and retry modification if valid.") | |
| 933 | |
| 837 class_obj.set(item_id, **props) | 934 class_obj.set(item_id, **props) |
| 838 self.db.commit() | 935 self.db.commit() |
| 839 except (TypeError, IndexError, ValueError) as message: | 936 except (TypeError, IndexError, ValueError) as message: |
| 840 raise ValueError(message) | 937 raise ValueError(message) |
| 841 | 938 |
| 874 try: | 971 try: |
| 875 op = input['op'].value.lower() | 972 op = input['op'].value.lower() |
| 876 except KeyError: | 973 except KeyError: |
| 877 op = self.__default_patch_op | 974 op = self.__default_patch_op |
| 878 class_obj = self.db.getclass(class_name) | 975 class_obj = self.db.getclass(class_name) |
| 976 | |
| 977 if not check_etag(class_obj.getnode(item_id), | |
| 978 obtain_etags(self.client.request.headers, input), | |
| 979 class_name, | |
| 980 item_id): | |
| 981 raise PreconditionFailed("Etag is missing or does not match." | |
| 982 "Retreive asset and retry modification if valid.") | |
| 879 | 983 |
| 880 # if patch operation is action, call the action handler | 984 # if patch operation is action, call the action handler |
| 881 action_args = [class_name + item_id] | 985 action_args = [class_name + item_id] |
| 882 if op == 'action': | 986 if op == 'action': |
| 883 # extract action_name and action_args from form fields | 987 # extract action_name and action_args from form fields |
| 976 (class_name, item_id, attr_name) | 1080 (class_name, item_id, attr_name) |
| 977 ) | 1081 ) |
| 978 | 1082 |
| 979 prop = attr_name | 1083 prop = attr_name |
| 980 class_obj = self.db.getclass(class_name) | 1084 class_obj = self.db.getclass(class_name) |
| 1085 | |
| 1086 if not check_etag(class_obj.getnode(item_id), | |
| 1087 obtain_etags(self.client.request.headers, input), | |
| 1088 class_name, | |
| 1089 item_id): | |
| 1090 raise PreconditionFailed("Etag is missing or does not match." | |
| 1091 "Retreive asset and retry modification if valid.") | |
| 1092 | |
| 981 props = { | 1093 props = { |
| 982 prop: self.prop_from_arg( | 1094 prop: self.prop_from_arg( |
| 983 class_obj, prop, input['data'].value, item_id | 1095 class_obj, prop, input['data'].value, item_id |
| 984 ) | 1096 ) |
| 985 } | 1097 } |
