You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/sandbox/grist/test_acl.py

513 lines
22 KiB

"""
Test of ACL rules.
"""
import acl
import actions
import logger
import schema
import testutil
import test_engine
import useractions
log = logger.Logger(__name__, logger.INFO)
class TestACL(test_engine.EngineTestCase):
maxDiff = None # Allow self.assertEqual to display big diffs
starting_table_data = [
["id", "city", "state", "amount" ],
[ 21, "New York", "NY" , 1. ],
[ 22, "Albany", "NY" , 2. ],
[ 23, "Seattle", "WA" , 3. ],
[ 24, "Chicago", "IL" , 4. ],
[ 25, "Bedford", "MA" , 5. ],
[ 26, "New York", "NY" , 6. ],
[ 27, "Buffalo", "NY" , 7. ],
[ 28, "Bedford", "NY" , 8. ],
[ 29, "Boston", "MA" , 9. ],
[ 30, "Yonkers", "NY" , 10. ],
[ 31, "New York", "NY" , 11. ],
]
sample = testutil.parse_test_sample({
"SCHEMA": [
[1, "Address", [
[11, "city", "Text", False, "", "City", ""],
[12, "state", "Text", False, "", "State", "WidgetOptions1"],
[13, "amount", "Numeric", False, "", "Amount", "WidgetOptions2"],
]]
],
"DATA": {
"Address": starting_table_data,
"_grist_ACLRules": [
["id", "resource", "permissions", "principals", "aclFormula", "aclColumn"],
],
"_grist_ACLResources": [
["id", "tableId", "colIds"],
],
"_grist_ACLPrincipals": [
["id", "type", "userEmail", "userName", "groupName", "instanceId"],
],
"_grist_ACLMemberships": [
["id", "parent", "child"],
]
}
})
def _apply_ua(self, *useraction_reprs):
"""Returns an ActionBundle."""
user_actions = [useractions.from_repr(ua) for ua in useraction_reprs]
return self.engine.acl_split(self.engine.apply_user_actions(user_actions))
def test_trivial_action_bundle(self):
# In this test case, we just check that an ActionGroup is packaged unchanged into an
# ActionBundle when there are no ACL rules at all.
self.load_sample(self.sample)
# Verify the starting table; there should be no views yet.
self.assertTableData("Address", self.starting_table_data)
# Check that the raw action group created by an action is as expected.
out_action = self.update_record("Address", 22, amount=20.)
self.assertPartialOutActions(out_action, {
'stored': [['UpdateRecord', 'Address', 22, {'amount': 20.}]],
'undo': [['UpdateRecord', 'Address', 22, {'amount': 2.}]],
'calc': [],
'retValues': [None],
})
# In this case, we have no rules, and the action is packaged unchanged into an ActionBundle.
out_bundle = self.engine.acl_split(out_action)
self.assertEqual(out_bundle.to_json_obj(), {
'envelopes': [{"recipients": []}],
'stored': [(0, ['UpdateRecord', 'Address', 22, {'amount': 20.}])],
'undo': [(0, ['UpdateRecord', 'Address', 22, {'amount': 2.}])],
'calc': [],
'retValues': [None],
'rules': [],
})
# Another similar action.
out_bundle = self._apply_ua(
['UpdateRecord', 'Address', 21, {'amount': 10., 'city': 'NYC'}])
self.assertEqual(out_bundle.to_json_obj(), {
'envelopes': [{"recipients": []}],
'stored': [(0, ['UpdateRecord', 'Address', 21, {'amount': 10., 'city': 'NYC'}])],
'undo': [(0, ['UpdateRecord', 'Address', 21, {'amount': 1., 'city': 'New York'}])],
'calc': [],
'retValues': [None],
'rules': [],
})
def test_bundle_default_rules(self):
# Check that a newly-created document (which should have default rules) produces the same
# bundle as the trivial document without rules.
self._apply_ua(['InitNewDoc', 'UTC'])
# Create a schema for a table, and fill with some data.
self.apply_user_action(["AddTable", "Address", [
{"id": "city", "type": "Text"},
{"id": "state", "type": "Text"},
{"id": "amount", "type": "Numeric"},
]])
self.add_records("Address", self.starting_table_data[0], self.starting_table_data[1:])
self.assertTableData("Address", cols="subset", data=self.starting_table_data)
# Check that an action creates the same bundle as in the trivial case.
out_bundle = self._apply_ua(
['UpdateRecord', 'Address', 21, {'amount': 10., 'city': 'NYC'}])
self.assertEqual(out_bundle.to_json_obj(), {
'envelopes': [{"recipients": []}],
'stored': [(0, ['UpdateRecord', 'Address', 21, {'amount': 10., 'city': 'NYC'}])],
'undo': [(0, ['UpdateRecord', 'Address', 21, {'amount': 1., 'city': 'New York'}])],
'calc': [],
'retValues': [None],
'rules': [1],
})
# Once we add principals to Owners group, they should show up in the recipient list.
self.add_records('_grist_ACLPrincipals', ['id', 'type', 'userName', 'instanceId'], [
[20, 'user', 'foo@grist', ''],
[21, 'instance', '', '12345'],
[22, 'instance', '', '0abcd'],
])
self.add_records('_grist_ACLMemberships', ['parent', 'child'], [
[1, 20], # group 'Owners' contains user 'foo@grist'
[20, 21], # user 'foo@grist', contains instance '12345' and '67890'
[20, 22],
])
# Similar action to before, which is bundled as a single envelope, but includes recipients.
out_bundle = self._apply_ua(
['UpdateRecord', 'Address', 21, {'amount': 11., 'city': 'NYC2'}])
self.assertEqual(out_bundle.to_json_obj(), {
'envelopes': [{"recipients": ['0abcd', '12345']}],
'stored': [(0, ['UpdateRecord', 'Address', 21, {'amount': 11., 'city': 'NYC2'}])],
'undo': [(0, ['UpdateRecord', 'Address', 21, {'amount': 10., 'city': 'NYC'}])],
'calc': [],
'retValues': [None],
'rules': [1],
})
def init_employees_doc(self):
# Create a document with non-trivial rules, and check that actions are split correctly,
# using col/table/default rules, and including undo and calc actions.
#
# This is the structure we create:
# Columns Name, Position
# VIEW permission to group Employees
# EDITOR permission to groups Managers, Owners
# Default for columns
# EDITOR permission to groups Managers, Owners
self._apply_ua(['InitNewDoc', 'UTC'])
self.apply_user_action(["AddTable", "Employees", [
{"id": "name", "type": "Text"},
{"id": "position", "type": "Text"},
{"id": "ssn", "type": "Text"},
{"id": "salary", "type": "Numeric", "isFormula": True,
"formula": "100000 if $position.startswith('Senior') else 60000"},
]])
# Set up some groups and instances (skip Users for simplicity). See the assert below for
# better view of the created structure.
self.add_records('_grist_ACLPrincipals', ['id', 'type', 'groupName', 'instanceId'], [
[21, 'group', 'Managers', ''],
[22, 'group', 'Employees', ''],
[23, 'instance', '', 'alice'],
[24, 'instance', '', 'bob'],
[25, 'instance', '', 'chuck'],
[26, 'instance', '', 'eve'],
[27, 'instance', '', 'zack'],
])
# Set up Alice and Bob as Managers; Alice, Chuck, Eve as Employees; and Zack as an Owner.
self.add_records('_grist_ACLMemberships', ['parent', 'child'], [
[21, 23], [21, 24],
[22, 23], [22, 25], [22, 26],
[1, 27]
])
self.assertTableData('_grist_ACLPrincipals', cols="subset", data=[
['id', 'name', 'allInstances' ],
[1, 'Group:Owners', [27] ],
[2, 'Group:Admins', [] ],
[3, 'Group:Editors', [] ],
[4, 'Group:Viewers', [] ],
[21, 'Group:Managers', [23,24] ],
[22, 'Group:Employees', [23,25,26] ],
[23, 'Inst:alice', [23] ],
[24, 'Inst:bob', [24] ],
[25, 'Inst:chuck', [25] ],
[26, 'Inst:eve', [26] ],
[27, 'Inst:zack', [27] ],
])
# Set up some ACL resources and rules: for columns "name,position", give VIEW permission to
# Employees, EDITOR to Managers+Owners; for the rest, just Editor to Managers+Owners.
self.add_records('_grist_ACLResources', ['id', 'tableId', 'colIds'], [
[2, 'Employees', 'name,position'],
[3, 'Employees', ''],
])
self.add_records('_grist_ACLRules', ['id', 'resource', 'permissions', 'principals'], [
[12, 2, acl.Permissions.VIEW, ['L', 22]],
[13, 2, acl.Permissions.EDITOR, ['L', 21,1]],
[14, 3, acl.Permissions.EDITOR, ['L', 21,1]],
])
# OK, now to some actions. The table starts out empty.
self.assertTableData('Employees', [['id', 'manualSort', 'name', 'position', 'salary', 'ssn']])
def test_rules_order(self):
# Test that shows the problem with the ordering of actions in Envelopes.
self.init_employees_doc()
self._apply_ua(self.add_records_action('Employees', [
['name', 'position', 'ssn'],
['John', 'Scientist', '000-00-0000'],
['Ellen', 'Senior Scientist', '111-11-1111'],
['Susie', 'Manager', '222-22-2222'],
['Frank', 'Senior Manager', '222-22-2222'],
]))
out_bundle = self._apply_ua(['ApplyDocActions', [
['UpdateRecord', 'Employees', 1, {'ssn': 'xxx-xx-0000'}],
['UpdateRecord', 'Employees', 1, {'position': 'Senior Jester'}],
['UpdateRecord', 'Employees', 1, {'ssn': 'yyy-yy-0000'}],
]])
self.assertTableData('Employees', cols="subset", data=[
['id', 'name', 'position', 'salary', 'ssn'],
[1, 'John', 'Senior Jester', 100000.0, 'yyy-yy-0000'],
[2, 'Ellen', 'Senior Scientist', 100000.0, '111-11-1111'],
[3, 'Susie', 'Manager', 60000.0, '222-22-2222'],
[4, 'Frank', 'Senior Manager', 100000.0, '222-22-2222'],
])
# Check the main aspects of the created bundles.
env = out_bundle.envelopes
# We expect two envelopes: one for Managers+Owners, one for all including Employees,
# because 'ssn' and 'position' columns are resources with different permissions.
# Note how non-consecutive actions may belong to the same envelope. This is needed to allow
# users (e.g. alice in this example) to process DocActions in the same order as how they were
# created, even when alice is present in different sets of recipients.
self.assertEqual(env[0].recipients, {"alice", "bob", "zack"})
self.assertEqual(env[1].recipients, {"alice", "bob", "zack", "chuck", "eve"})
self.assertEqual(out_bundle.stored, [
(0, actions.UpdateRecord('Employees', 1, {'ssn': 'xxx-xx-0000'})),
(1, actions.UpdateRecord('Employees', 1, {'position': 'Senior Jester'})),
(0, actions.UpdateRecord('Employees', 1, {'ssn': 'yyy-yy-0000'})),
])
self.assertEqual(out_bundle.calc, [
(0, actions.UpdateRecord('Employees', 1, {'salary': 100000.00}))
])
def test_with_rules(self):
self.init_employees_doc()
out_bundle = self._apply_ua(self.add_records_action('Employees', [
['name', 'position', 'ssn'],
['John', 'Scientist', '000-00-0000'],
['Ellen', 'Senior Scientist', '111-11-1111'],
['Susie', 'Manager', '222-22-2222'],
['Frank', 'Senior Manager', '222-22-2222'],
]))
# Check the main aspects of the output.
env = out_bundle.envelopes
# We expect two envelopes: one for Managers+Owners, one for all including Employees.
self.assertEqual([e.recipients for e in env], [
{"alice","chuck","eve","bob","zack"},
{"alice", "bob", "zack"}
])
# Only "name" and "position" are sent to Employees; the rest only to Managers+Owners.
self.assertEqual([(env, set(a.columns)) for (env, a) in out_bundle.stored], [
(0, {"name", "position"}),
(1, {"ssn", "manualSort"}),
])
self.assertEqual([(env, set(a.columns)) for (env, a) in out_bundle.calc], [
(1, {"salary"})
])
# Full bundle requires careful reading. See the checks above for the essential parts.
self.assertEqual(out_bundle.to_json_obj(), {
"envelopes": [
{"recipients": [ "alice", "bob", "chuck", "eve", "zack" ]},
{"recipients": [ "alice", "bob", "zack" ]},
],
"stored": [
# TODO Yikes, there is a problem here! We have two envelopes, each with BulkAddRecord
# actions, but some recipients receive BOTH envelopes. What is "alice" to do with two
# separate BulkAddRecord actions that both include rowIds 1, 2, 3, 4?
(0, [ "BulkAddRecord", "Employees", [ 1, 2, 3, 4 ], {
"position": [ "Scientist", "Senior Scientist", "Manager", "Senior Manager" ],
"name": [ "John", "Ellen", "Susie", "Frank" ]
}]),
(1, [ "BulkAddRecord", "Employees", [ 1, 2, 3, 4 ], {
"manualSort": [ 1, 2, 3, 4 ],
"ssn": [ "000-00-0000", "111-11-1111", "222-22-2222", "222-22-2222" ]
}]),
],
"undo": [
# TODO All recipients now get BulkRemoveRecord (which is correct), but some get it twice,
# which is a simpler manifestation of the problem with BulkAddRecord.
(0, [ "BulkRemoveRecord", "Employees", [ 1, 2, 3, 4 ] ]),
(1, [ "BulkRemoveRecord", "Employees", [ 1, 2, 3, 4 ] ]),
],
"calc": [
(1, [ "BulkUpdateRecord", "Employees", [ 1, 2, 3, 4 ], {
"salary": [ 60000, 100000, 60000, 100000 ]
}])
],
"retValues": [[1, 2, 3, 4]],
"rules": [12,13,14],
})
def test_empty_add_record(self):
self.init_employees_doc()
out_bundle = self._apply_ua(['AddRecord', 'Employees', None, {}])
self.assertEqual(out_bundle.to_json_obj(), {
"envelopes": [{"recipients": [ "alice", "bob", "chuck", "eve", "zack" ]},
{"recipients": [ "alice", "bob", "zack" ]} ],
# TODO Note the same issues as in previous test case: some recipients receive duplicate or
# near-duplicate AddRecord and RemoveRecord actions, governed by different rules.
"stored": [
(0, [ "AddRecord", "Employees", 1, {}]),
(1, [ "AddRecord", "Employees", 1, {"manualSort": 1.0}]),
],
"undo": [
(0, [ "RemoveRecord", "Employees", 1 ]),
(1, [ "RemoveRecord", "Employees", 1 ]),
],
"calc": [
(1, [ "UpdateRecord", "Employees", 1, { "salary": 60000.0 }])
],
"retValues": [1],
"rules": [12,13,14],
})
out_bundle = self._apply_ua(['UpdateRecord', 'Employees', 1, {"position": "Senior Citizen"}])
self.assertEqual(out_bundle.to_json_obj(), {
"envelopes": [{"recipients": [ "alice", "bob", "chuck", "eve", "zack" ]},
{"recipients": [ "alice", "bob", "zack" ]} ],
"stored": [
(0, [ "UpdateRecord", "Employees", 1, {"position": "Senior Citizen"}]),
],
"undo": [
(0, [ "UpdateRecord", "Employees", 1, {"position": ""}]),
],
"calc": [
(1, [ "UpdateRecord", "Employees", 1, { "salary": 100000.0 }])
],
"retValues": [None],
"rules": [12,13,14],
})
def test_add_user(self):
self.init_employees_doc()
out_bundle = self._apply_ua(['AddUser', 'f@g.c', 'Fred', ['XXX', 'YYY']])
self.assertEqual(out_bundle.to_json_obj(), {
# TODO: Only Owners are getting these metadata changes, but all users should get them.
"envelopes": [{"recipients": [ "XXX", "YYY", "zack" ]}],
"stored": [
(0, [ "AddRecord", "_grist_ACLPrincipals", 28, {
'type': 'user', 'userEmail': 'f@g.c', 'userName': 'Fred'}]),
(0, [ "BulkAddRecord", "_grist_ACLPrincipals", [29, 30], {
'type': ['instance', 'instance'],
'instanceId': ['XXX', 'YYY']
}]),
(0, [ "BulkAddRecord", "_grist_ACLMemberships", [7, 8, 9], {
# Adds instances (29, 30) to user (28), and user (28) to group owners (1)
'parent': [28, 28, 1],
'child': [29, 30, 28],
}]),
],
"undo": [
(0, [ "RemoveRecord", "_grist_ACLPrincipals", 28]),
(0, [ "BulkRemoveRecord", "_grist_ACLPrincipals", [29, 30]]),
(0, [ "BulkRemoveRecord", "_grist_ACLMemberships", [7, 8, 9]]),
],
"calc": [
],
"retValues": [None],
"rules": [1],
})
def test_doc_snapshot(self):
self.init_employees_doc()
# Apply an action to the initial employees doc to make the test case more complex
self.add_records('Employees', ['name', 'position', 'ssn'], [
['John', 'Scientist', '000-00-0000'],
['Ellen', 'Senior Scientist', '111-11-1111'],
['Susie', 'Manager', '222-22-2222'],
['Frank', 'Senior Manager', '222-22-2222']
])
# Retrieve the doc snapshot and split it
snapshot_action_group = self.engine.fetch_snapshot()
snapshot_bundle = self.engine.acl_split(snapshot_action_group)
init_schema_actions = [actions.get_action_repr(a) for a in schema.schema_create_actions()]
# We check that the unsplit doc snapshot bundle includes all the necessary actions
# to rebuild the doc
snapshot = snapshot_action_group.get_repr()
self.assertEqual(snapshot['calc'], [])
self.assertEqual(snapshot['retValues'], [])
self.assertEqual(snapshot['undo'], [])
stored_subset = [
['AddTable', 'Employees',
[{'formula': '','id': 'manualSort','isFormula': False,'type': 'ManualSortPos'},
{'formula': '','id': 'name','isFormula': False,'type': 'Text'},
{'formula': '','id': 'position','isFormula': False,'type': 'Text'},
{'formula': '','id': 'ssn','isFormula': False,'type': 'Text'},
{'formula': "100000 if $position.startswith('Senior') else 60000",
'id': 'salary',
'isFormula': True,
'type': 'Numeric'}]],
['BulkAddRecord', '_grist_Tables', [1],
{'primaryViewId': [1],
'summarySourceTable': [0],
'tableId': ['Employees'],
'onDemand': [False]}],
['BulkAddRecord', 'Employees', [1, 2, 3, 4], {
'manualSort': [1.0, 2.0, 3.0, 4.0],
'name': ['John', 'Ellen', 'Susie', 'Frank'],
'position': ['Scientist', 'Senior Scientist', 'Manager', 'Senior Manager'],
'ssn': ['000-00-0000', '111-11-1111', '222-22-2222', '222-22-2222']
}],
['BulkAddRecord','_grist_Tables_column',[1, 2, 3, 4, 5],
{'colId': ['manualSort', 'name', 'position', 'ssn', 'salary'],
'displayCol': [0, 0, 0, 0, 0],
'formula': ['','','','',"100000 if $position.startswith('Senior') else 60000"],
'isFormula': [False, False, False, False, True],
'label': ['manualSort', 'name', 'position', 'ssn', 'salary'],
'parentId': [1, 1, 1, 1, 1],
'parentPos': [1.0, 2.0, 3.0, 4.0, 5.0],
'summarySourceCol': [0, 0, 0, 0, 0],
'type': ['ManualSortPos', 'Text', 'Text', 'Text', 'Numeric'],
'untieColIdFromLabel': [False, False, False, False, False],
'widgetOptions': ['', '', '', '', ''],
'visibleCol': [0, 0, 0, 0, 0]}]
]
for action in stored_subset:
self.assertIn(action, snapshot['stored'])
# We check that the full doc snapshot bundle is split as expected
snapshot_bundle_json = snapshot_bundle.to_json_obj()
self.assertEqual(snapshot_bundle_json['envelopes'], [
{'recipients': ['#ALL']},
{'recipients': ['zack']},
{'recipients': ['alice', 'bob', 'chuck', 'eve', 'zack']},
{'recipients': ['alice', 'bob', 'zack']}
])
self.assertEqual(snapshot_bundle_json['calc'], [])
self.assertEqual(snapshot_bundle_json['retValues'], [])
self.assertEqual(snapshot_bundle_json['undo'], [])
self.assertEqual(snapshot_bundle_json['rules'], [1, 12, 13, 14])
stored_subset = ([(0, action_repr) for action_repr in init_schema_actions] + [
(0, ['AddTable', 'Employees',
[{'formula': '','id': 'manualSort','isFormula': False,'type': 'ManualSortPos'},
{'formula': '','id': 'name','isFormula': False,'type': 'Text'},
{'formula': '','id': 'position','isFormula': False,'type': 'Text'},
{'formula': '','id': 'ssn','isFormula': False,'type': 'Text'},
{'formula': "100000 if $position.startswith('Senior') else 60000",
'id': 'salary',
'isFormula': True,
'type': 'Numeric'}]]),
# TODO (High-priority): The following action only received by 'zack' when it should be
# received by everyone.
(1, ['BulkAddRecord', '_grist_Tables', [1],
{'primaryViewId': [1],
'summarySourceTable': [0],
'tableId': ['Employees'],
'onDemand': [False]}]),
(2, ['BulkAddRecord', 'Employees', [1, 2, 3, 4],
{'name': ['John', 'Ellen', 'Susie', 'Frank'],
'position': ['Scientist', 'Senior Scientist', 'Manager', 'Senior Manager']}]),
(3, ['BulkAddRecord', 'Employees', [1, 2, 3, 4],
{'manualSort': [1.0, 2.0, 3.0, 4.0],
'ssn': ['000-00-0000', '111-11-1111', '222-22-2222', '222-22-2222']}]),
(1, ['BulkAddRecord', '_grist_Tables_column', [1, 2, 3, 4, 5],
{'colId': ['manualSort','name','position','ssn','salary'],
'displayCol': [0, 0, 0, 0, 0],
'formula': ['','','','',"100000 if $position.startswith('Senior') else 60000"],
'isFormula': [False, False, False, False, True],
'label': ['manualSort','name','position','ssn','salary'],
'parentId': [1, 1, 1, 1, 1],
'parentPos': [1.0, 2.0, 3.0, 4.0, 5.0],
'summarySourceCol': [0, 0, 0, 0, 0],
'type': ['ManualSortPos','Text','Text','Text','Numeric'],
'untieColIdFromLabel': [False, False, False, False, False],
'widgetOptions': ['', '', '', '', ''],
'visibleCol': [0, 0, 0, 0, 0]}])
])
for action in stored_subset:
self.assertIn(action, snapshot_bundle_json['stored'])