Skip to content

Commit f506eb0

Browse files
authored
feat: Field Level Perms (#66)
* feat: Field Level Perms * fix: keywords in field names
1 parent a8419fb commit f506eb0

File tree

7 files changed

+149
-9
lines changed

7 files changed

+149
-9
lines changed

frappe_graphql/utils/cursor_pagination.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
import frappe
55

6+
from frappe_graphql.utils.permissions import get_allowed_fieldnames_for_doctype
7+
68

79
class CursorPaginator(object):
810
def __init__(
@@ -142,12 +144,16 @@ def get_data(self, doctype, filters, sorting_fields, sort_dir, limit):
142144

143145
return frappe.get_list(
144146
doctype,
145-
fields=["*", f"SUBSTR(\".{doctype}\", 2) as doctype"] + sorting_fields,
147+
fields=self.get_fields_to_fetch(doctype, filters, sorting_fields),
146148
filters=filters,
147149
order_by=f"{', '.join([f'{x} {sort_dir}' for x in sorting_fields])}",
148150
limit_page_length=limit
149151
)
150152

153+
def get_fields_to_fetch(self, doctype, filters, sorting_fields):
154+
fieldnames = get_allowed_fieldnames_for_doctype(doctype)
155+
return list(set(fieldnames + [f"SUBSTR(\".{doctype}\", 2) as doctype"] + sorting_fields))
156+
151157
def get_sort_args(self, sorting_input=None):
152158
sort_dir = self.default_sorting_direction if self.default_sorting_direction in (
153159
"asc", "desc") else "desc"

frappe_graphql/utils/permissions.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import frappe
2+
from frappe.model import default_fields, no_value_fields
3+
from frappe.model.meta import Meta
4+
5+
6+
def get_allowed_fieldnames_for_doctype(doctype: str, parent_doctype: str = None):
7+
"""
8+
Gets a list of fieldnames that's allowed for the current User to
9+
read on the specified doctype. This includes default_fields
10+
"""
11+
fieldnames = list(default_fields) + ["\"{}\" as `doctype`".format(doctype)]
12+
fieldnames.remove("doctype")
13+
14+
meta = frappe.get_meta(doctype)
15+
has_access_to = _get_permlevel_read_access(meta=frappe.get_meta(parent_doctype or doctype))
16+
if not has_access_to:
17+
return []
18+
19+
for df in meta.fields:
20+
if df.fieldtype in no_value_fields:
21+
continue
22+
23+
if df.permlevel is not None and df.permlevel not in has_access_to:
24+
continue
25+
26+
fieldnames.append(df.fieldname)
27+
28+
return fieldnames
29+
30+
31+
def _get_permlevel_read_access(meta: Meta):
32+
ptype = "read"
33+
_has_access_to = []
34+
roles = frappe.get_roles()
35+
for perm in meta.permissions:
36+
if perm.get("role") not in roles or not perm.get(ptype):
37+
continue
38+
39+
if perm.get("permlevel") in _has_access_to:
40+
continue
41+
42+
_has_access_to.append(perm.get("permlevel"))
43+
44+
return _has_access_to

frappe_graphql/utils/resolver/child_tables.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ def setup_child_table_resolvers(schema: GraphQLSchema):
2323

2424

2525
def _child_table_resolver(obj, info: GraphQLResolveInfo, **kwargs):
26+
# If the obj already has a non None value, we can return it.
27+
# This happens when the resolver returns a full doc
28+
if obj.get(info.field_name) is not None:
29+
return obj.get(info.field_name)
30+
2631
df = getattr(info.parent_type.fields[info.field_name], "frappe_docfield", None)
2732
if not df:
2833
return []

frappe_graphql/utils/resolver/dataloaders/child_table_loader.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
from collections import OrderedDict
2+
13
import frappe
24

35
from frappe_graphql.utils.execution import DataLoader
6+
from frappe_graphql.utils.permissions import get_allowed_fieldnames_for_doctype
47
from .locals import get_loader_from_locals, set_loader_in_locals
58

6-
from collections import OrderedDict
7-
89

910
def get_child_table_loader(child_doctype: str, parent_doctype: str, parentfield: str) -> DataLoader:
1011
locals_key = (child_doctype, parent_doctype, parentfield)
@@ -23,10 +24,16 @@ def get_child_table_loader(child_doctype: str, parent_doctype: str, parentfield:
2324

2425
def _get_child_table_loader_fn(child_doctype: str, parent_doctype: str, parentfield: str):
2526
def _inner(keys):
27+
fieldnames = get_allowed_fieldnames_for_doctype(
28+
doctype=child_doctype,
29+
parent_doctype=parent_doctype
30+
)
31+
32+
select_fields = ", ".join([f"`{x}`" if "`" not in x else x for x in fieldnames])
33+
2634
rows = frappe.db.sql(f"""
27-
SELECT
28-
*,
29-
"{child_doctype}" as doctype
35+
SELECT
36+
{select_fields}
3037
FROM `tab{child_doctype}`
3138
WHERE
3239
parent IN %(parent_keys)s

frappe_graphql/utils/resolver/dataloaders/doctype_loader.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import frappe
44

55
from frappe_graphql.utils.execution import DataLoader
6+
from frappe_graphql.utils.permissions import get_allowed_fieldnames_for_doctype
67
from .locals import get_loader_from_locals, set_loader_in_locals
78

89

@@ -17,12 +18,13 @@ def get_doctype_dataloader(doctype: str) -> DataLoader:
1718

1819

1920
def _get_document_loader_fn(doctype: str):
21+
fieldnames = get_allowed_fieldnames_for_doctype(doctype)
2022

2123
def _load_documents(keys: List[str]):
2224
docs = frappe.get_list(
2325
doctype=doctype,
2426
filters=[["name", "IN", keys]],
25-
fields=["*", f"'{doctype}' as doctype"],
27+
fields=fieldnames,
2628
limit_page_length=len(keys) + 1
2729
)
2830

frappe_graphql/utils/resolver/root_query.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
from frappe_graphql import CursorPaginator
77

8-
from .dataloaders import get_doctype_dataloader
98
from .utils import get_singular_doctype, get_plural_doctype
109

1110

@@ -39,7 +38,9 @@ def _get_doc_resolver(obj, info: GraphQLResolveInfo, **kwargs):
3938
if not frappe.has_permission(doctype=dt, doc=dn):
4039
raise frappe.PermissionError(frappe._("No permission for {0}").format(dt + " " + dn))
4140

42-
return get_doctype_dataloader(dt).load(dn)
41+
doc = frappe.get_doc(dt, dn)
42+
doc.apply_fieldlevel_read_permissions()
43+
return doc
4344

4445

4546
def _doc_cursor_resolver(obj, info: GraphQLResolveInfo, **kwargs):
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from unittest import TestCase
2+
from unittest.mock import patch
3+
4+
import frappe
5+
from frappe.model import no_value_fields, default_fields
6+
7+
from ..permissions import get_allowed_fieldnames_for_doctype
8+
9+
10+
class TestGetAllowedFieldNameForDocType(TestCase):
11+
def setUp(self) -> None:
12+
pass
13+
14+
def tearDown(self) -> None:
15+
frappe.set_user("Administrator")
16+
17+
def test_admin_on_user(self):
18+
"""
19+
Administrator on User doctype
20+
"""
21+
meta = frappe.get_meta("User")
22+
fieldnames = get_allowed_fieldnames_for_doctype("User")
23+
self.assertCountEqual(
24+
fieldnames,
25+
[x.fieldname for x in meta.fields if x.fieldtype not in no_value_fields]
26+
+ [x for x in default_fields if x != "doctype"]
27+
+ ["\"{}\" as doctype".format(meta.name)]
28+
)
29+
30+
def test_permlevels_on_user(self):
31+
frappe.set_user("Guest")
32+
33+
# Guest is given permlevel=0 access on User DocType
34+
user_meta = self._get_custom_user_meta()
35+
36+
with patch("frappe.get_meta") as get_meta_mock:
37+
get_meta_mock.return_value = user_meta
38+
fieldnames = get_allowed_fieldnames_for_doctype(user_meta.name)
39+
40+
self.assertCountEqual(
41+
fieldnames,
42+
[x.fieldname for x in user_meta.fields
43+
if x.permlevel == 1 and x.fieldtype not in no_value_fields]
44+
+ [x for x in default_fields if x != "doctype"]
45+
+ ["\"{}\" as doctype".format(user_meta.name)]
46+
)
47+
48+
# Clear meta_cache for User doctype
49+
del frappe.local.meta_cache["User"]
50+
51+
def test_on_child_doctype(self):
52+
fieldnames = get_allowed_fieldnames_for_doctype("Has Role", parent_doctype="User")
53+
meta = frappe.get_meta("Has Role")
54+
self.assertCountEqual(
55+
fieldnames,
56+
[x.fieldname for x in meta.fields if x.fieldtype not in no_value_fields]
57+
+ [x for x in default_fields if x != "doctype"]
58+
+ ["\"{}\" as doctype".format(meta.name)]
59+
)
60+
61+
def test_on_child_doctype_with_no_parent_doctype(self):
62+
fieldnames = get_allowed_fieldnames_for_doctype("Has Role")
63+
self.assertEqual(fieldnames, [])
64+
65+
def _get_custom_user_meta(self):
66+
meta = frappe.get_meta("User")
67+
meta.permissions.append(dict(
68+
role="Guest",
69+
read=1,
70+
permlevel=1
71+
))
72+
73+
meta.get_field("full_name").permlevel = 1
74+
75+
return meta

0 commit comments

Comments
 (0)