Mercurial > p > roundup > code
diff 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 |
line wrap: on
line diff
--- a/roundup/rest.py Wed Feb 27 21:47:39 2019 -0500 +++ b/roundup/rest.py Fri Mar 01 22:57:07 2019 -0500 @@ -25,6 +25,8 @@ from roundup.exceptions import * from roundup.cgi.exceptions import * +from hashlib import md5 + # Py3 compatible basestring try: basestring @@ -59,6 +61,9 @@ except ValueError as msg: code = 409 data = msg + except PreconditionFailed as msg: + code = 412 + data = msg except NotImplementedError: code = 402 # nothing to pay, just a mark for debugging purpose data = 'Method under development' @@ -91,6 +96,66 @@ return result return format_object +def calculate_etag (node, classname="Missing", id="0"): + '''given a hyperdb node generate a hashed representation of it to be + used as an etag. + + This code needs a __repr__ function in the Password class. This + replaces the repr(items) which would be: + + <roundup.password.Password instance at 0x7f3442406170> + + with the string representation: + + {PBKDF2}10000$k4d74EDgxlbH...A + + This makes the representation repeatable as the location of the + password instance is not static and we need a constant value to + calculate the etag. + + Note that repr() is chosen for the node rather than str() since + repr is meant to be an unambiguous representation. + + classname and id are used for logging only. + ''' + + items = node.items(protected=True) # include every item + etag = md5(repr(items)).hexdigest() + logger.debug("object=%s%s; tag=%s; repr=%s", classname, id, + etag, repr(node.items(protected=True))) + return etag + +def check_etag (node, etags, classname="Missing", id="0"): + '''Take a list of etags and compare to the etag for the given node. + + Iterate over all supplied etags, + If a tag fails to match, return False. + If at least one etag matches, return True. + If all etags are None, return False. + + ''' + have_etag_match=False + + node_etag = calculate_etag(node, classname, id) + + for etag in etags: + if etag != None: + if etag != node_etag: + return False + have_etag_match=True + + if have_etag_match: + return True + else: + return False + +def obtain_etags(headers,input): + '''Get ETags value from headers or payload data''' + etags = [] + if '@etag' in input: + etags.append(input['@etag'].value); + etags.append(headers.getheader("ETag", None)) + return etags def parse_accept_header(accept): """ @@ -484,6 +549,8 @@ ) class_obj = self.db.getclass(class_name) + node = class_obj.getnode(item_id) + etag = calculate_etag(node, class_name, item_id) props = None for form_field in input.value: key = form_field.name @@ -496,7 +563,7 @@ try: result = [ - (prop_name, class_obj.get(item_id, prop_name)) + (prop_name, node.__getattr__(prop_name)) for prop_name in props if self.db.security.hasPermission( 'View', self.db.getuid(), class_name, prop_name, @@ -508,9 +575,11 @@ 'id': item_id, 'type': class_name, 'link': '%s/%s/%s' % (self.data_path, class_name, item_id), - 'attributes': dict(result) + 'attributes': dict(result), + '@etag': etag } + self.client.setHeader("ETag", '"%s"'%etag) return 200, result @Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'GET') @@ -546,15 +615,19 @@ ) class_obj = self.db.getclass(class_name) - data = class_obj.get(item_id, attr_name) + node = class_obj.getnode(item_id) + etag = calculate_etag(node, class_name, item_id) + data = node.__getattr__(attr_name) result = { 'id': item_id, 'type': type(data), 'link': "%s/%s/%s/%s" % (self.data_path, class_name, item_id, attr_name), - 'data': data + 'data': data, + '@etag': etag } + self.client.setHeader("ETag", '"%s"'%etag ) return 200, result @Routing.route("/data/<:class_name>", 'POST') @@ -655,6 +728,12 @@ (p, class_name, item_id) ) try: + if not check_etag(class_obj.getnode(item_id), + obtain_etags(self.client.request.headers, input), + class_name, + item_id): + raise PreconditionFailed("Etag is missing or does not match." + "Retreive asset and retry modification if valid.") result = class_obj.set(item_id, **props) self.db.commit() except (TypeError, IndexError, ValueError) as message: @@ -697,7 +776,6 @@ 'Permission to edit %s%s %s denied' % (class_name, item_id, attr_name) ) - class_obj = self.db.getclass(class_name) props = { attr_name: self.prop_from_arg( @@ -706,6 +784,11 @@ } try: + if not check_etag(class_obj.getnode(item_id), + obtain_etags(self.client.request.headers, input), + class_name, item_id): + raise PreconditionFailed("Etag is missing or does not match." + "Retreive asset and retry modification if valid.") result = class_obj.set(item_id, **props) self.db.commit() except (TypeError, IndexError, ValueError) as message: @@ -791,6 +874,13 @@ 'Permission to retire %s %s denied' % (class_name, item_id) ) + if not check_etag(class_obj.getnode(item_id), + obtain_etags(self.client.request.headers, input), + class_name, + item_id): + raise PreconditionFailed("Etag is missing or does not match." + "Retreive asset and retry modification if valid.") + class_obj.retire (item_id) self.db.commit() result = { @@ -834,6 +924,13 @@ props[attr_name] = None try: + if not check_etag(class_obj.getnode(item_id), + obtain_etags(self.client.request.headers, input), + class_name, + item_id): + raise PreconditionFailed("Etag is missing or does not match." + "Retreive asset and retry modification if valid.") + class_obj.set(item_id, **props) self.db.commit() except (TypeError, IndexError, ValueError) as message: @@ -877,6 +974,13 @@ op = self.__default_patch_op class_obj = self.db.getclass(class_name) + if not check_etag(class_obj.getnode(item_id), + obtain_etags(self.client.request.headers, input), + class_name, + item_id): + raise PreconditionFailed("Etag is missing or does not match." + "Retreive asset and retry modification if valid.") + # if patch operation is action, call the action handler action_args = [class_name + item_id] if op == 'action': @@ -978,6 +1082,14 @@ prop = attr_name class_obj = self.db.getclass(class_name) + + if not check_etag(class_obj.getnode(item_id), + obtain_etags(self.client.request.headers, input), + class_name, + item_id): + raise PreconditionFailed("Etag is missing or does not match." + "Retreive asset and retry modification if valid.") + props = { prop: self.prop_from_arg( class_obj, prop, input['data'].value, item_id
