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 }

Roundup Issue Tracker: http://roundup-tracker.org/