Skip to content

Commit 97c0061

Browse files
committed
Allow transitive properties in @fields in REST API
These transitive properties may not cross Multilinks, e.g., when querying 'issue' the property 'messages.author' is not allowed (because 'messages' is a multilink). A multilink at the end (e.g. messages in the example) is fine.
1 parent 332280d commit 97c0061

File tree

4 files changed

+102
-28
lines changed

4 files changed

+102
-28
lines changed

CHANGES.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ Features:
5353
request handler from the request displatcher. Also allow
5454
customisation of tracker instance creation via an overridable
5555
"get_tracker" context manager.
56+
- Allow transitive properties in @fields in REST API. These transitive
57+
properties may not cross Multilinks, e.g., when querying 'issue' the
58+
property 'messages.author' is not allowed (because 'messages' is a
59+
multilink). A multilink at the end (e.g. messages in the example) is
60+
fine.
5661

5762
Fixed:
5863

doc/rest.txt

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -508,21 +508,25 @@ In addition collections support the ``@fields`` parameter which is a
508508
colon or comma separated list of fields to embed in the response. For
509509
example ``https://.../rest/data/issue?@verbose=2`` is the same as:
510510
``https://.../rest/data/issue?@fields=title`` since the label property
511-
for an issue is its title. You can use both ``@verbose`` and
511+
for an issue is its title.
512+
The @fields option supports transitive properties, e.g.
513+
``status.name``. The transitive property may not include multilinks in
514+
the path except for the last component. So ``messages.author`` is not
515+
allowed because ``messages`` is a multilink while ``messages`` alone
516+
would be allowed. You can use both ``@verbose`` and
512517
``@fields`` to get additional info. For example
513518
``https://.../rest/data/issue?@verbose=2&@fields=status`` returns::
514519

520+
515521
{
516522
"data": {
517523
"collection": [
518524
{
519-
"link":
520-
"https://.../rest/data/issue/1",
525+
"link": "https://.../rest/data/issue/1",
521526
"title": "Welcome to the tracker START HERE",
522527
"id": "1",
523528
"status": {
524-
"link":
525-
"https://.../rest/data/status/1",
529+
"link": "https://.../rest/data/status/1",
526530
"id": "1",
527531
"name": "new"
528532
}
@@ -841,7 +845,8 @@ By default all (visible to the current user) attributes/properties are
841845
returned. You can limit this by using the ``@fields`` query parameter
842846
similar to how it is used in collections. This way you can only return
843847
the fields you are interested in reducing network load as well as
844-
memory and parsing time on the client side. By default protected
848+
memory and parsing time on the client side. Or you can add additional
849+
transitive properties. By default protected
845850
properties (read only in the database) are not listed. This
846851
makes it easier to submit the attributes from a
847852
``@verbose=0`` query using PUT. To include protected properties

roundup/rest.py

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,45 @@ def prop_from_arg(self, cl, key, value, itemid=None):
470470

471471
return prop
472472

473+
def transitive_props (self, class_name, props):
474+
"""Construct a list of transitive properties from the given
475+
argument, and return it after permission check. Raises
476+
Unauthorised if no permission. Permission is checked by checking
477+
search-permission on the path (delimited by '.') excluding the
478+
last component and then checking View permission on the last
479+
component. We do not allow to traverse multilinks -- the last
480+
item of an expansion *may* be a multilink but in the middle of a
481+
transitive prop.
482+
"""
483+
checked_props = []
484+
uid = self.db.getuid()
485+
for p in props:
486+
pn = p
487+
cn = class_name
488+
if '.' in p:
489+
path, lc = p.rsplit('.', 1)
490+
if not self.db.security.hasSearchPermission(uid, class_name, p):
491+
raise (Unauthorised
492+
('User does not have permission on "%s.%s"'
493+
% (class_name, p)))
494+
prev = prop = None
495+
# This shouldn't raise any errors, otherwise the search
496+
# permission check above would have failed
497+
for pn in path.split('.'):
498+
cls = self.db.getclass(cn)
499+
prop = cls.getprops(protected=True)[pn]
500+
if isinstance(prop, hyperdb.Multilink):
501+
raise UsageError(
502+
'Multilink Traversal not allowed: %s' % p)
503+
cn = prop.classname
504+
cls = self.db.getclass(cn)
505+
# Now we have the classname in cn and the prop name in pn.
506+
if not self.db.security.hasPermission('View', uid, cn, pn):
507+
raise(Unauthorised
508+
('User does not have permission on "%s.%s"' % (cn, pn)))
509+
checked_props.append (p)
510+
return checked_props
511+
473512
def error_obj(self, status, msg, source=None):
474513
"""Return an error object"""
475514
self.client.response_code = status
@@ -567,12 +606,24 @@ def format_item(self, node, item_id, props=None, verbose=1):
567606
try:
568607
# pn = propname
569608
for pn in sorted(props):
570-
prop = props[pn]
571-
if not self.db.security.hasPermission(
572-
'View', uid, class_name, pn, item_id
573-
):
609+
ok = False
610+
id = item_id
611+
nd = node
612+
cn = class_name
613+
for p in pn.split('.'):
614+
if not self.db.security.hasPermission(
615+
'View', uid, cn, p, id
616+
):
617+
break
618+
cl = self.db.getclass(cn)
619+
nd = cl.getnode(id)
620+
id = v = getattr(nd, p)
621+
prop = cl.getprops(protected=True)[p]
622+
cn = getattr(prop, 'classname', None)
623+
else:
624+
ok = True
625+
if not ok:
574626
continue
575-
v = getattr(node, pn)
576627
if isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
577628
linkcls = self.db.getclass(prop.classname)
578629
cp = '%s/%s/' % (self.data_path, prop.classname)
@@ -654,7 +705,7 @@ def get_collection(self, class_name, input):
654705
'index': 1 # setting just size starts at page 1
655706
}
656707
verbose = 1
657-
display_props = {}
708+
display_props = set()
658709
sort = []
659710
for form_field in input.value:
660711
key = form_field.name
@@ -670,12 +721,7 @@ def get_collection(self, class_name, input):
670721
if len(f) == 1:
671722
f = value.split(":")
672723
allprops = class_obj.getprops(protected=True)
673-
for i in f:
674-
try:
675-
display_props[i] = allprops[i]
676-
except KeyError:
677-
raise UsageError("Failed to find property '%s' "
678-
"for class %s." % (i, class_name))
724+
display_props.update(self.transitive_props(class_name, f))
679725
elif key == "@sort":
680726
f = value.split(",")
681727
allprops = class_obj.getprops(protected=True)
@@ -780,8 +826,7 @@ def get_collection(self, class_name, input):
780826
# add verbose elements. 2 and above get identifying label.
781827
if verbose > 1:
782828
lp = class_obj.labelprop()
783-
# Label prop may be a protected property like activity
784-
display_props[lp] = class_obj.getprops(protected=True)[lp]
829+
display_props.add(lp)
785830

786831
# extract result from data
787832
result = {}
@@ -891,17 +936,13 @@ def get_element(self, class_name, item_id, input):
891936
value = form_field.value
892937
if key == "@fields" or key == "@attrs":
893938
if props is None:
894-
props = {}
939+
props = set()
895940
# support , or : separated elements
896941
f = value.split(",")
897942
if len(f) == 1:
898943
f = value.split(":")
899944
allprops = class_obj.getprops(protected=True)
900-
for i in f:
901-
try:
902-
props[i] = allprops[i]
903-
except KeyError:
904-
raise UsageError("Failed to find property '%s' for class %s." % (i, class_name))
945+
props.update(self.transitive_props(class_name, f))
905946
elif key == "@protected":
906947
# allow client to request read only
907948
# properties like creator, activity etc.
@@ -912,11 +953,11 @@ def get_element(self, class_name, item_id, input):
912953

913954
result = {}
914955
if props is None:
915-
props = class_obj.getprops(protected=protected)
956+
props = set(class_obj.getprops(protected=protected))
916957
else:
917958
if verbose > 1:
918959
lp = class_obj.labelprop()
919-
props[lp] = class_obj.properties[lp]
960+
props.add(lp)
920961

921962
result = {
922963
'id': itemid,

test/rest_common.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pytest
12
import unittest
23
import os
34
import shutil
@@ -666,6 +667,28 @@ def testSorting(self):
666667
results = self.server.get_collection('issue', form)
667668
self.assertDictEqual(expected, results)
668669

670+
def testTransitiveField(self):
671+
""" Test a transitive property in @fields """
672+
base_path = self.db.config['TRACKER_WEB'] + 'rest/data/'
673+
# create sample data
674+
self.create_stati()
675+
self.db.issue.create(
676+
title='foo4',
677+
status=self.db.status.lookup('closed'),
678+
priority=self.db.priority.lookup('critical')
679+
)
680+
# Retrieve all issue @fields=status.name
681+
form = cgi.FieldStorage()
682+
form.list = [
683+
cgi.MiniFieldStorage('@fields', 'status.name')
684+
]
685+
results = self.server.get_collection('issue', form)
686+
self.assertEqual(self.dummy_client.response_code, 200)
687+
688+
exp = [
689+
{'link': base_path + 'issue/1', 'id': '1', 'status.name': 'closed'}]
690+
self.assertEqual(results['data']['collection'], exp)
691+
669692
def testFilter(self):
670693
"""
671694
Retrieve all three users

0 commit comments

Comments
 (0)