gristlabs_grist-core/sandbox/grist/test_acl.py
Dmitry S e2226c3ab7 (core) Store formula values in DB, and include them into .stored/.undo fields of actions.
Summary:
- Introduce a new SQLiteDB migration, which adds DB columns for formula columns
- Newly added columns have the special ['P'] (pending) value in them
  (in order to show the usual "Loading..." on the first load that triggers the migration)
- Calculated values are added to .stored/.undo fields of user actions.
- Various changes made in the sandbox to include .stored/.undo in the right order.
- OnDemand tables ignore stored formula columns, replacing them with special SQL as before
- In particular, converting to OnDemand table leaves stale values in those
  columns, we should maybe clean those out.

Some tweaks on the side:
- Allow overriding chai assertion truncateThreshold with CHAI_TRUNCATE_THRESHOLD
- Rebuild python automatically in watch mode

Test Plan: Fixed various tests, updated some fixtures. Many python tests that check actions needed adjustments because actions moved from .stored to .undo. Some checks added to catch situations previously only caught in browser tests.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2645
2020-11-04 16:45:47 -05:00

509 lines
22 KiB
Python

"""
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'})),
(0, actions.UpdateRecord('Employees', 1, {'salary': 100000.00})),
])
self.assertEqual(out_bundle.calc, [])
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"}),
(1, {"salary"}),
])
self.assertEqual([(env, set(a.columns)) for (env, a) in out_bundle.calc], [])
# 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" ]
}]),
(1, [ "BulkUpdateRecord", "Employees", [ 1, 2, 3, 4 ], {
"salary": [ 60000, 100000, 60000, 100000 ]
}]),
],
"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": [],
"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}]),
(1, [ "UpdateRecord", "Employees", 1, { "salary": 60000.0 }]),
],
"undo": [
(0, [ "RemoveRecord", "Employees", 1 ]),
(1, [ "RemoveRecord", "Employees", 1 ]),
],
"calc": [],
"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"}]),
(1, [ "UpdateRecord", "Employees", 1, { "salary": 100000.0 }])
],
"undo": [
(0, [ "UpdateRecord", "Employees", 1, {"position": ""}]),
(1, [ "UpdateRecord", "Employees", 1, { "salary": 60000.0 }])
],
"calc": [],
"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'])