Skip to content

Commit 7c78778

Browse files
committed
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.
1 parent 02fae16 commit 7c78778

File tree

4 files changed

+322
-15
lines changed

4 files changed

+322
-15
lines changed

roundup/cgi/exceptions.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ class NotFound(HTTPException):
1818
class NotModified(HTTPException):
1919
pass
2020

21+
class PreconditionFailed(HTTPException):
22+
pass
23+
2124
class DetectorError(BaseException):
2225
"""Raised when a detector throws an exception.
2326
Contains details of the exception."""

roundup/password.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,9 @@ def __init__(self, plaintext=None, scheme=None, encrypted=None, strict=False, co
294294
self.password = None
295295
self.plaintext = None
296296

297+
def __repr__(self):
298+
return self.__str__()
299+
297300
def needs_migration(self):
298301
""" Password has insecure scheme or other insecure parameters
299302
and needs migration to new password scheme

roundup/rest.py

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
from roundup.exceptions import *
2626
from roundup.cgi.exceptions import *
2727

28+
from hashlib import md5
29+
2830
# Py3 compatible basestring
2931
try:
3032
basestring
@@ -59,6 +61,9 @@ def format_object(self, *args, **kwargs):
5961
except ValueError as msg:
6062
code = 409
6163
data = msg
64+
except PreconditionFailed as msg:
65+
code = 412
66+
data = msg
6267
except NotImplementedError:
6368
code = 402 # nothing to pay, just a mark for debugging purpose
6469
data = 'Method under development'
@@ -91,6 +96,66 @@ def format_object(self, *args, **kwargs):
9196
return result
9297
return format_object
9398

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
94159

95160
def parse_accept_header(accept):
96161
"""
@@ -484,6 +549,8 @@ def get_element(self, class_name, item_id, input):
484549
)
485550

486551
class_obj = self.db.getclass(class_name)
552+
node = class_obj.getnode(item_id)
553+
etag = calculate_etag(node, class_name, item_id)
487554
props = None
488555
for form_field in input.value:
489556
key = form_field.name
@@ -496,7 +563,7 @@ def get_element(self, class_name, item_id, input):
496563

497564
try:
498565
result = [
499-
(prop_name, class_obj.get(item_id, prop_name))
566+
(prop_name, node.__getattr__(prop_name))
500567
for prop_name in props
501568
if self.db.security.hasPermission(
502569
'View', self.db.getuid(), class_name, prop_name,
@@ -508,9 +575,11 @@ def get_element(self, class_name, item_id, input):
508575
'id': item_id,
509576
'type': class_name,
510577
'link': '%s/%s/%s' % (self.data_path, class_name, item_id),
511-
'attributes': dict(result)
578+
'attributes': dict(result),
579+
'@etag': etag
512580
}
513581

582+
self.client.setHeader("ETag", '"%s"'%etag)
514583
return 200, result
515584

516585
@Routing.route("/data/<:class_name>/<:item_id>/<:attr_name>", 'GET')
@@ -546,15 +615,19 @@ def get_attribute(self, class_name, item_id, attr_name, input):
546615
)
547616

548617
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)
550621
result = {
551622
'id': item_id,
552623
'type': type(data),
553624
'link': "%s/%s/%s/%s" %
554625
(self.data_path, class_name, item_id, attr_name),
555-
'data': data
626+
'data': data,
627+
'@etag': etag
556628
}
557629

630+
self.client.setHeader("ETag", '"%s"'%etag )
558631
return 200, result
559632

560633
@Routing.route("/data/<:class_name>", 'POST')
@@ -655,6 +728,12 @@ def put_element(self, class_name, item_id, input):
655728
(p, class_name, item_id)
656729
)
657730
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.")
658737
result = class_obj.set(item_id, **props)
659738
self.db.commit()
660739
except (TypeError, IndexError, ValueError) as message:
@@ -697,7 +776,6 @@ def put_attribute(self, class_name, item_id, attr_name, input):
697776
'Permission to edit %s%s %s denied' %
698777
(class_name, item_id, attr_name)
699778
)
700-
701779
class_obj = self.db.getclass(class_name)
702780
props = {
703781
attr_name: self.prop_from_arg(
@@ -706,6 +784,11 @@ def put_attribute(self, class_name, item_id, attr_name, input):
706784
}
707785

708786
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.")
709792
result = class_obj.set(item_id, **props)
710793
self.db.commit()
711794
except (TypeError, IndexError, ValueError) as message:
@@ -791,6 +874,13 @@ def delete_element(self, class_name, item_id, input):
791874
'Permission to retire %s %s denied' % (class_name, item_id)
792875
)
793876

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+
794884
class_obj.retire (item_id)
795885
self.db.commit()
796886
result = {
@@ -834,6 +924,13 @@ def delete_attribute(self, class_name, item_id, attr_name, input):
834924
props[attr_name] = None
835925

836926
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+
837934
class_obj.set(item_id, **props)
838935
self.db.commit()
839936
except (TypeError, IndexError, ValueError) as message:
@@ -877,6 +974,13 @@ def patch_element(self, class_name, item_id, input):
877974
op = self.__default_patch_op
878975
class_obj = self.db.getclass(class_name)
879976

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.")
983+
880984
# if patch operation is action, call the action handler
881985
action_args = [class_name + item_id]
882986
if op == 'action':
@@ -978,6 +1082,14 @@ def patch_attribute(self, class_name, item_id, attr_name, input):
9781082

9791083
prop = attr_name
9801084
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+
9811093
props = {
9821094
prop: self.prop_from_arg(
9831095
class_obj, prop, input['data'].value, item_id

0 commit comments

Comments
 (0)