(core) Remove the old attempt at ACLs implemented in Python.

Summary:
The new plans for granular access control are different and handled by
node.js. Some of the same tables will be reused, of which we never made
real use before except for expecting certain specific initial records.

This diff removes the old logic, replacing it with a stub that satisfies
the interface expected by other code.

It also removes several unused UserActions: AddUser/RemoveUser/
AddInstance/RemoveInstance.

Test Plan: Existing tests should pass.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2662
pull/3/head
Dmitry S 4 years ago
parent 5b2de988b5
commit 6b582b9ace

@ -790,8 +790,7 @@ export class ActiveDoc extends EventEmitter {
}
public async removeInstanceFromDoc(docSession: DocSession): Promise<void> {
const instanceId = await this._sharing.removeInstanceFromDoc();
await this._applyUserActions(docSession, [['RemoveInstance', instanceId]]);
await this._sharing.removeInstanceFromDoc();
}
public async renameDocTo(docSession: OptDocSession, newName: string): Promise<void> {

@ -42,10 +42,7 @@ const SPECIAL_ACTIONS = new Set(['InitNewDoc',
]);
// Odd-ball actions marked as deprecated or which seem unlikely to be used.
const SURPRISING_ACTIONS = new Set(['AddUser',
'RemoveUser',
'AddInstance',
'RemoveInstance',
const SURPRISING_ACTIONS = new Set([
'RemoveView',
'AddViewSection',
]);

@ -1,84 +1,8 @@
# Access Control Lists.
#
# This modules is used by engine.py to split actions according to recipient, as well as to
# validate whether an action received from a peer is allowed by the rules.
# This file used to implement (partially) old plans for granular ACLs.
# It now retains only the minimum needed to keep new documents openable by old code,
# and to produce the ActionBundles expected by other code.
# Where are ACLs applied?
# -----------------------
# Read ACLs (which control who can see data) are implemented by "acl_read_split" operation, which
# takes an action group and returns an action bundle, which is a list of ActionEnvelopes, each
# containing a smaller action group associated with a set of recipients who should get it. Note
# that the order of ActionEnvelopes matters, and actions should be applied in that order.
#
# In principle, this operation can be done either in the Python data engine or in Node. We do it
# in Python. The clearest reason is the need to apply ACL formulas. Not that it's impossible to do
# on the Node side, but currently formula values are only maintained on the Python side.
# UserActions and ACLs
# --------------------
# Each actions starts with a UserAction, which is turned by the data engine into a number of
# DocActions. We then split DocActions by recipient according to ACL rules. But should recipients
# receive UserActions too?
#
# If UserAction is shared, we need to split it similarly to docactions, because it will often
# contain data that some recipients should not see (e.g. a BulkUpdateRecord user-action generated
# by a copy-paste). An additional difficulty is that splitting by recipient may sometimes require
# creating multiple actions. Further, trimmed UserActions aren't enough for purposes (2) or (3).
#
# Our solution will be not to send around UserActions at all, since DocActions are sufficient to
# update a document. But UserActions are needed for some things:
# (1) Present a meaningful description to users for the action log. This should be possible, and
# may in fact be better, to do from docactions only.
# (2) Redo actions. We currently use UserActions for this, but we can treat this as "undo of the
# undo", relying on docactions, which is in fact more general. Any difficulties with that
# are the same as for Undo, and are not specific to Redo anyway.
# (3) Rebase pending actions after getting peers' actions from the hub. This only needs to be
# done by the authoring instance, which will keep its original UserAction. We don't need to
# share the UserAction for this purpose.
# Initial state
# -------------
# With sharing enabled, the ACL rules have particular defaults (in particular, with the current
# user included in the Owners group, and that group having full access in the default rule).
# Before sharing is enabled, this cannot be completely set up, because the current user is
# unknown, nor are the user's instances, and the initial instance may not even have an instanceId.
#
# Our approach is that default rules and groups are created immediately, before sharing is
# enabled. The Owners group stays empty, and action bundles end up destined for an empty list of
# recipients. Node handles empty list of recipients as its own instanceId when sharing is off.
#
# When sharing is enabled, actions are sent to add a user with the user's instances, including the
# current instance's real instanceId, to the Owners group, and Node stops handling empty list of
# recipients as special, relying on the presence of the actual instanceId instead.
# Identifying tables and columns
# ------------------------------
# If we use tableId and colId in rules, then rules need to be adjusted when tables or columns are
# renamed or removed. If we use tableRef and colRef, then such rules cannot apply to metadata
# tables (which don't have refs at all).
#
# Additionally, a benefit of using tableId and colId is that this is how actions identify tables
# and columns, so rules can be applied to actions without additional lookups.
#
# For these reasons, we use tableId and colId, rather than refs (row-ids).
# Summary tables
# --------------
# It's not sufficient to identify summary tables by their actual tableId or by tableRef, since
# both may change when summaries are removed and recreated. They should instead be identified
# by a value similar to tableTitle() in DocModel.js, specifically by the combination of source
# tableId and colIds of all group-by columns.
# Actions on Permission Changes
# -----------------------------
# When VIEW principals are added or removed for a table/column, or a VIEW ACLFormula adds or
# removes principals for a row, those principals need to receive AddRecord or RemoveRecord
# doc-actions (or equivalent). TODO: This needs to be handled.
from collections import OrderedDict
import action_obj
import logger
log = logger.Logger(__name__, logger.INFO)
class Permissions(object):
# Permission types and their combination are represented as bits of a single integer.
@ -92,316 +16,23 @@ class Permissions(object):
ADMIN = EDITOR | SCHEMA_EDIT
OWNER = ADMIN | ACL_EDIT
@classmethod
def includes(cls, superset, subset):
return (superset & subset) == subset
@classmethod
def includes_view(cls, permissions):
return cls.includes(permissions, cls.VIEW)
# Sentinel object to represent "all rows", for internal use in this file.
_ALL_ROWS = "ALL_ROWS"
# Representation of ACL resources that's used by the ACL class. An instance of this class becomes
# the value of DocInfo.acl_resources formula.
# Note that the default ruleset (for tableId None, colId None) must exist.
# TODO: ensure that the default ruleset is created, and cannot be deleted.
class ResourceMap(object):
def __init__(self, resource_records):
self._col_resources = {} # Maps table_id to [(resource, col_id_set), ...]
self._default_resources = {} # Maps table_id (or None for global default) to resource record.
for resource in resource_records:
# Note that resource.tableId is the empty string ('') for the default table (represented as
# None in self._default_resources), and resource.colIds is '' for the table's default rule.
table_id = resource.tableId or None
if not resource.colIds:
self._default_resources[table_id] = resource
elif resource.colIds.startswith('~'):
# Rows with colIds that start with '~' are for trial purposes - ignore.
pass
else:
col_id_set = set(resource.colIds.split(','))
self._col_resources.setdefault(table_id, []).append((resource, col_id_set))
def get_col_resources(self, table_id):
"""
Returns a list of (resource, col_id_set) pairs, where resource is a record in ACLResources.
"""
return self._col_resources.get(table_id, [])
def get_default_resource(self, table_id):
"""
Returns the "default" resource record for the given table.
"""
return self._default_resources.get(table_id) or self._default_resources.get(None)
# Used by docmodel.py for DocInfo.acl_resources formula.
def build_resources(resource_records):
return ResourceMap(resource_records)
class ACL(object):
# Special recipients, or instanceIds. ALL is the special recipient for schema actions that
# should be shared with all collaborators of the document.
ALL = '#ALL'
ALL_SET = frozenset([ALL])
EMPTY_SET = frozenset([])
def __init__(self, docmodel):
self._docmodel = docmodel
def get_acl_resources(self):
try:
return self._docmodel.doc_info.table.get_record(1).acl_resources
except KeyError:
return None
def _find_resources(self, table_id, col_ids):
"""
Yields tuples (resource, col_id_set) where each col_id_set represents the intersection of the
resouces's columns with col_ids. These intersections may be empty.
If col_ids is None, then it's treated as "all columns", and each col_id_set represents all of
the resource's columns. For the default resource then, it yields (resource, None)
"""
resource_map = self.get_acl_resources()
if col_ids is None:
for resource, col_id_set in resource_map.get_col_resources(table_id):
yield resource, col_id_set
resource = resource_map.get_default_resource(table_id)
yield resource, None
else:
seen = set()
for resource, col_id_set in resource_map.get_col_resources(table_id):
seen.update(col_id_set)
yield resource, col_id_set.intersection(col_ids)
resource = resource_map.get_default_resource(table_id)
yield resource, set(c for c in col_ids if c not in seen)
@classmethod
def _acl_read_split_rows(cls, resource, row_ids):
"""
Scans through ACL rules for the resouce, yielding tuples of the form (rule, row_id,
instances), to say which rowId should be sent to each set of instances according to the rule.
"""
for rule in resource.ruleset:
if not Permissions.includes_view(rule.permissions):
continue
common_instances = _get_instances(rule.principalsList)
if rule.aclColumn and row_ids is not None:
for (row_id, principals) in get_row_principals(rule.aclColumn, row_ids):
yield (rule, row_id, common_instances | _get_instances(principals))
else:
yield (rule, _ALL_ROWS, common_instances)
@classmethod
def _acl_read_split_instance_sets(cls, resource, row_ids):
"""
Yields tuples of the form (instances, rowset, rules) for different sets of instances, to say
which rowIds for the given resource should be sent to each set of instances, and which rules
enabled that. When a set of instances should get all rows, rowset is None.
"""
for instances, group in _group((instances, (rule, row_id)) for (rule, row_id, instances)
in cls._acl_read_split_rows(resource, row_ids)):
rules = set(item[0] for item in group)
rowset = frozenset(item[1] for item in group)
yield (instances, _ALL_ROWS if _ALL_ROWS in rowset else rowset, rules)
@classmethod
def _acl_read_split_resource(cls, resource, row_ids, docaction, output):
"""
Given an ACLResource record and optionally row_ids (which may be None), appends to output
tuples of the form `(instances, rules, action)`, where `action` is docaction itself or a part
of it that should be sent to the corresponding set of instances.
"""
if docaction is None:
return
# Different rules may produce different recipients for the same set of rows. We group outputs
# by sets of rows (which determine a subaction), and take a union of all the recipients.
for rowset, group in _group((rowset, (instances, rules)) for (instances, rowset, rules)
in cls._acl_read_split_instance_sets(resource, row_ids)):
da = docaction if rowset is _ALL_ROWS else _subaction(docaction, row_ids=rowset)
if da is not None:
all_instances = frozenset(i for item in group for i in item[0])
all_rules = set(r for item in group for r in item[1])
output.append((all_instances, all_rules, da))
def _acl_read_split_docaction(self, docaction, output):
"""
Given just a docaction, appends to output tuples of the form `(instances, rules, action)`,
where `action` is docaction itself or a part of it that should be sent to `instances`, and
`rules` is the set of ACLRules that allowed that (empty set for schema actions).
"""
parts = _get_docaction_parts(docaction)
if parts is None: # This is a schema action, to send to everyone.
# We want to send schema actions to everyone on the document, represented by None.
output.append((ACL.ALL_SET, set(), docaction))
return
table_id, row_ids, col_ids = parts
for resource, col_id_set in self._find_resources(table_id, col_ids):
da = _subaction(docaction, col_ids=col_id_set)
if da is not None:
self._acl_read_split_resource(resource, row_ids, da, output)
def _acl_read_split_docactions(self, docactions):
"""
Returns a list of tuples `(instances, rules, action)`. See _acl_read_split_docaction.
"""
if not self.get_acl_resources():
return [(ACL.EMPTY_SET, None, da) for da in docactions]
output = []
for da in docactions:
self._acl_read_split_docaction(da, output)
return output
# Special recipients, or instanceIds. ALL is the special recipient for schema actions that
# should be shared with all collaborators of the document.
ALL = '#ALL'
ALL_SET = frozenset([ALL])
def acl_read_split(self, action_group):
"""
Returns an ActionBundle, containing actions from the given action_group, split by the sets of
instances to which actions should be sent.
"""
bundle = action_obj.ActionBundle()
envelopeIndices = {} # Maps instance-sets to envelope indices.
def getEnvIndex(instances):
envIndex = envelopeIndices.setdefault(instances, len(bundle.envelopes))
if envIndex == len(bundle.envelopes):
bundle.envelopes.append(action_obj.Envelope(instances))
return envIndex
def split_into_envelopes(docactions, out_rules, output):
for (instances, rules, action) in self._acl_read_split_docactions(docactions):
output.append((getEnvIndex(instances), action))
if rules:
out_rules.update(r.id for r in rules)
split_into_envelopes(action_group.stored, bundle.rules, bundle.stored)
split_into_envelopes(action_group.calc, bundle.rules, bundle.calc)
split_into_envelopes(action_group.undo, bundle.rules, bundle.undo)
bundle.retValues = action_group.retValues
return bundle
class OrderedDefaultListDict(OrderedDict):
def __missing__(self, key):
self[key] = value = []
return value
def _group(iterable_of_pairs):
"""
Group iterable of pairs (a, b), returning pairs (a, [list of b]). The order of the groups, and
of items within a group, is according to the first seen.
def acl_read_split(action_group):
"""
groups = OrderedDefaultListDict()
for key, value in iterable_of_pairs:
groups[key].append(value)
return groups.iteritems()
def _get_instances(principals):
"""
Returns a frozenset of all instances for all passed-in principals.
"""
instances = set()
for p in principals:
instances.update(i.instanceId for i in p.allInstances)
return frozenset(instances)
def get_row_principals(_acl_column, _rows):
# TODO TBD. Need to implement this (with tests) for acl-formulas for row-level access control.
return []
#----------------------------------------------------------------------
def _get_docaction_parts(docaction):
Returns an ActionBundle containing actions from the given action_group, all in one envelope.
With the deprecation of old-style ACL rules, envelopes are not used at all, and only kept to
avoid triggering unrelated code changes.
"""
Returns a tuple of (table_id, row_ids, col_ids), any of whose members may be None, or None if
this action should not get split.
"""
return _docaction_part_helpers[docaction.__class__.__name__](docaction)
# Helpers for _get_docaction_parts to extract for each action type the table, rows, and columns
# that a docaction of that type affects. Note that we are only talking here about the data
# affected. Schema actions do not get trimmed, since we decided against having a separate
# (and confusing) "SCHEMA_VIEW" permission. All peers will know the schema.
_docaction_part_helpers = {
'AddRecord' : lambda a: (a.table_id, [a.row_id], a.columns.keys()),
'BulkAddRecord' : lambda a: (a.table_id, a.row_ids, a.columns.keys()),
'RemoveRecord' : lambda a: (a.table_id, [a.row_id], None),
'BulkRemoveRecord' : lambda a: (a.table_id, a.row_ids, None),
'UpdateRecord' : lambda a: (a.table_id, [a.row_id], a.columns.keys()),
'BulkUpdateRecord' : lambda a: (a.table_id, a.row_ids, a.columns.keys()),
'ReplaceTableData' : lambda a: (a.table_id, a.row_ids, a.columns.keys()),
'AddColumn' : lambda a: None,
'RemoveColumn' : lambda a: None,
'RenameColumn' : lambda a: None,
'ModifyColumn' : lambda a: None,
'AddTable' : lambda a: None,
'RemoveTable' : lambda a: None,
'RenameTable' : lambda a: None,
}
#----------------------------------------------------------------------
def _subaction(docaction, row_ids=None, col_ids=None):
"""
For data actions, extracts and returns a part of docaction that applies only to the given
row_ids and/or col_ids, if given. If the part of the action is empty, returns None.
"""
helper = _subaction_helpers[docaction.__class__.__name__]
try:
return docaction.__class__._make(helper(docaction, row_ids, col_ids))
except _NoMatch:
return None
# Helpers for _subaction(), one for each action type, which return the tuple of values for the
# trimmed action. From this tuple a new action is automatically created by _subaction. If any part
# of the action becomes empty, the helpers raise _NoMatch exception.
_subaction_helpers = {
# pylint: disable=line-too-long
'AddRecord' : lambda a, r, c: (a.table_id, match(r, a.row_id), match_keys_keep_empty(c, a.columns)),
'BulkAddRecord' : lambda a, r, c: (a.table_id, match_list(r, a.row_ids), match_keys_keep_empty(c, a.columns)),
'RemoveRecord' : lambda a, r, c: (a.table_id, match(r, a.row_id)),
'BulkRemoveRecord' : lambda a, r, c: (a.table_id, match_list(r, a.row_ids)),
'UpdateRecord' : lambda a, r, c: (a.table_id, match(r, a.row_id), match_keys_skip_empty(c, a.columns)),
'BulkUpdateRecord' : lambda a, r, c: (a.table_id, match_list(r, a.row_ids), match_keys_skip_empty(c, a.columns)),
'ReplaceTableData' : lambda a, r, c: (a.table_id, match_list(r, a.row_ids), match_keys_keep_empty(c, a.columns)),
'AddColumn' : lambda a, r, c: a,
'RemoveColumn' : lambda a, r, c: a,
'RenameColumn' : lambda a, r, c: a,
'ModifyColumn' : lambda a, r, c: a,
'AddTable' : lambda a, r, c: a,
'RemoveTable' : lambda a, r, c: a,
'RenameTable' : lambda a, r, c: a,
}
def match(subset, item):
return item if (subset is None or item in subset) else no_match()
def match_list(subset, items):
return items if subset is None else ([i for i in items if i in subset] or no_match())
def match_keys_keep_empty(subset, items):
return items if subset is None else (
{k: v for (k, v) in items.iteritems() if k in subset})
def match_keys_skip_empty(subset, items):
return items if subset is None else (
{k: v for (k, v) in items.iteritems() if k in subset} or no_match())
class _NoMatch(Exception):
pass
def no_match():
raise _NoMatch()
bundle = action_obj.ActionBundle()
bundle.envelopes.append(action_obj.Envelope(ALL_SET))
bundle.stored.extend((0, da) for da in action_group.stored)
bundle.calc.extend((0, da) for da in action_group.calc)
bundle.undo.extend((0, da) for da in action_group.undo)
bundle.retValues = action_group.retValues
return bundle

@ -6,15 +6,15 @@ which exist only in the sandbox and are not communicated to the client.
It is similar in purpose to DocModel.js on the client side.
"""
import itertools
import json
import acl
import records
import usertypes
import relabeling
import table
import moment
# pylint:disable=redefined-outer-name
def _record_set(table_id, group_by, sort_by=None):
@usertypes.formulaType(usertypes.ReferenceList(table_id))
def func(rec, table):
@ -38,14 +38,6 @@ class MetaTableExtras(object):
"""
# pylint: disable=no-self-argument,no-member,unused-argument,not-an-iterable
class _grist_DocInfo(object):
def acl_resources(rec, table):
"""
Returns a map of ACL resources for use by acl.py. It is done in a formula so that it
automatically recomputes when anything changes in _grist_ACLResources table.
"""
# pylint: disable=no-self-use
return acl.build_resources(table.docmodel.get_table('_grist_ACLResources').lookupRecords())
@usertypes.formulaType(usertypes.Any())
def tzinfo(rec, table):
# pylint: disable=no-self-use
@ -105,53 +97,6 @@ class MetaTableExtras(object):
class _grist_Views_section(object):
fields = _record_set('_grist_Views_section_field', 'parentId', sort_by='parentPos')
class _grist_ACLRules(object):
# The set of rules that applies to this resource
@usertypes.formulaType(usertypes.ReferenceList('_grist_ACLPrincipals'))
def principalsList(rec, table):
return json.loads(rec.principals)
class _grist_ACLResources(object):
# The set of rules that applies to this resource
ruleset = _record_set('_grist_ACLRules', 'resource')
class _grist_ACLPrincipals(object):
# Memberships table maintains containment relationships between principals.
memberships = _record_set('_grist_ACLMemberships', 'parent')
# Children of a User principal are Instances. Children of a Group are Users or other Groups.
@usertypes.formulaType(usertypes.ReferenceList('_grist_ACLPrincipals'))
def children(rec, table):
return [m.child for m in rec.memberships]
@usertypes.formulaType(usertypes.ReferenceList('_grist_ACLPrincipals'))
def descendants(rec, table):
"""
Descendants through great-grandchildren. (We don't support fully recursive descendants yet,
which may be cleaner.) The max supported level is a group containing subgroups (children),
which contain users (grandchildren), which contain instances (great-grandchildren).
"""
# Include direct children.
ret = set(rec.children)
ret.add(rec)
for c1 in rec.children:
# Include grandchildren (children of each child)
ret.update(c1.children)
for c2 in c1.children:
# Include great-grandchildren (children of each grandchild).
ret.update(c2.children)
return ret
@usertypes.formulaType(usertypes.ReferenceList('_grist_ACLPrincipals'))
def allInstances(rec, table):
return sorted(r for r in rec.descendants if r.instanceId)
@usertypes.formulaType(usertypes.Text())
def name(rec, table):
return ('User:' + rec.userEmail if rec.type == 'user' else
'Group:' + rec.groupName if rec.type == 'group' else
'Inst:' + rec.instanceId if rec.type == 'instance' else '')
def enhance_model(model_class):
"""
@ -201,10 +146,6 @@ class DocModel(object):
self.validations = self._prep_table("_grist_Validations")
self.repl_hist = self._prep_table("_grist_REPL_Hist")
self.attachments = self._prep_table("_grist_Attachments")
self.acl_rules = self._prep_table("_grist_ACLRules")
self.acl_resources = self._prep_table("_grist_ACLResources")
self.acl_principals = self._prep_table("_grist_ACLPrincipals")
self.acl_memberships = self._prep_table("_grist_ACLMemberships")
self.pages = self._prep_table("_grist_Pages")
def _prep_table(self, name):

@ -215,9 +215,6 @@ class Engine(object):
# Locals dict for recently executed code in the REPL
self._repl = repl.REPLInterpreter()
# The single ACL instance for breaking up and validating actions according to permissions.
self._acl = acl.ACL(self.docmodel)
# Stores an exception representing the first unevaluated cell met while recomputing the
# current cell.
self._cell_required_error = None
@ -996,9 +993,6 @@ class Engine(object):
# Update the context used for autocompletions.
self._autocomplete_context = AutocompleteContext(self.gencode.usercode.__dict__)
# TODO: Whenever schema changes, we need to adjust the ACL resources to remove or rename
# tableIds and colIds.
def _update_table_model(self, table, user_table):
"""
@ -1120,7 +1114,8 @@ class Engine(object):
Splits ActionGroups, as returned e.g. from apply_user_actions, by permissions. Returns a
single ActionBundle containing of all of the original action_groups.
"""
return self._acl.acl_read_split(action_group)
# pylint:disable=no-self-use
return acl.acl_read_split(action_group)
def _apply_one_user_action(self, user_action):
"""

@ -238,7 +238,7 @@ def schema_create_actions():
make_column('colIds', 'Text'), # Comma-separated list of colIds, or ''
]),
# All of the principals used by ACL rules, including users, groups, and instances.
# DEPRECATED: All of the principals used by ACL rules, including users, groups, and instances.
actions.AddTable('_grist_ACLPrincipals', [
make_column('type', 'Text'), # 'user', 'group', or 'instance'
make_column('userEmail', 'Text'), # For 'user' principals
@ -250,51 +250,12 @@ def schema_create_actions():
# only: `memberships`, `children`, and `descendants`.
]),
# Table for containment relationships between Principals, e.g. user contains multiple
# instances, group contains multiple users, and groups may contain other groups.
# DEPRECATED: Table for containment relationships between Principals, e.g. user contains
# multiple instances, group contains multiple users, and groups may contain other groups.
actions.AddTable('_grist_ACLMemberships', [
make_column('parent', 'Ref:_grist_ACLPrincipals'),
make_column('child', 'Ref:_grist_ACLPrincipals'),
]),
# TODO:
# The Data Engine should not load up the action log or be able to modify it, or know anything
# about it. It's bad if users could hack up data engine logic to mess with history. (E.g. if
# share a doc for editing, and peer tries to hack it, want to know that can revert; i.e. peer
# shouldn't be able to destroy history.) Also, the action log could be big. It's nice to keep
# it in sqlite and not take up memory.
#
# For this reason, JS code perhaps should be the one creating action tables for a new
# document. It should also ignore any actions that attempt to change such tables. I.e. it
# should have some protected tables, perhaps with a different prefix (_gristsys_), which can't
# be changed by actions generated from the data engine.
#
# TODO
# Conversion of schema actions to metadata-change actions perhaps should also be done by JS,
# and metadata tables should be protected (i.e. can't be changed by user). Hmm....
# # The actions that fully determine the history of this database.
# actions.AddTable("_grist_Action", [
# make_column("num", "Int"), # Action-group number
# make_column("time", "Int"), # Milliseconds since Epoch
# make_column("user", "Text"), # User performing this action
# make_column("desc", "Text"), # Action description
# make_column("otherId", "Int"), # For Undo and Redo, id of the other action
# make_column("linkId", "Int"), # Id of the prev action in the same bundle
# make_column("json", "Text"), # JSON representation of the action
# ]),
# # A logical action is comprised potentially of multiple steps.
# actions.AddTable("_grist_Action_step", [
# make_column("parentId", "Ref:_grist_Action"),
# make_column("type", "Text"), # E.g. "undo", "stored"
# make_column("name", "Text"), # E.g. "AddRecord" or "RenameTable"
# make_column("tableId", "Text"), # Name of the table
# make_column("colIds", "Text"), # Comma-separated names of affected columns
# make_column("rowIds", "Text"), # Comma-separated IDs of affected rows
# make_column("values", "Text"), # All values for the affected rows and columns,
# # bundled together, column-wise, as a JSON array.
# ]),
]

@ -1,508 +0,0 @@
"""
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'])

@ -731,152 +731,6 @@ class TestUserActions(test_engine.EngineTestCase):
#----------------------------------------------------------------------
def test_acl_principal_actions(self):
# Test the AddUser, RemoveUser, AddInstance and RemoveInstance actions.
self.load_sample(self.sample)
# Add two users
out_actions = self.apply_user_action(['AddUser', 'jake@grist.com', 'Jake', ['i001', 'i002']])
self.assertPartialOutActions(out_actions, { "stored": [
["AddRecord", "_grist_ACLPrincipals", 1, {
"type": "user",
"userEmail": "jake@grist.com",
"userName": "Jake"
}],
["BulkAddRecord", "_grist_ACLPrincipals", [2, 3], {
"instanceId": ["i001", "i002"],
"type": ["instance", "instance"]
}],
["BulkAddRecord", "_grist_ACLMemberships", [1, 2], {
"child": [2, 3],
"parent": [1, 1]
}]
]})
out_actions = self.apply_user_action(['AddUser', 'steve@grist.com', 'Steve', ['i003']])
self.assertPartialOutActions(out_actions, { "stored": [
["AddRecord", "_grist_ACLPrincipals", 4, {
"type": "user",
"userEmail": "steve@grist.com",
"userName": "Steve"
}],
["AddRecord", "_grist_ACLPrincipals", 5, {
"instanceId": "i003",
"type": "instance"
}],
["AddRecord", "_grist_ACLMemberships", 3, {
"child": 5,
"parent": 4
}]
]})
self.assertTableData('_grist_ACLPrincipals', cols="subset", data=[
["id", "type", "userEmail", "userName", "groupName", "instanceId"],
[1, "user", "jake@grist.com", "Jake", "", ""],
[2, "instance", "", "", "", "i001"],
[3, "instance", "", "", "", "i002"],
[4, "user", "steve@grist.com", "Steve", "", ""],
[5, "instance", "", "", "", "i003"],
])
self.assertTableData('_grist_ACLMemberships', cols="subset", data=[
["id", "parent", "child"],
[1, 1, 2],
[2, 1, 3],
[3, 4, 5]
])
# Add an instance to a non-existent user
with self.assertRaisesRegexp(ValueError, "Cannot find existing user with email null@grist.com"):
self.apply_user_action(['AddInstance', 'null@grist.com', 'i003'])
# Add an instance to an existing user
out_actions = self.apply_user_action(['AddInstance', 'jake@grist.com', 'i004'])
self.assertPartialOutActions(out_actions, { "stored": [
["AddRecord", "_grist_ACLPrincipals", 6, {
"instanceId": "i004",
"type": "instance"
}],
["AddRecord", "_grist_ACLMemberships", 4, {
"child": 6,
"parent": 1
}]
]})
self.assertTableData('_grist_ACLPrincipals', cols="subset", data=[
["id", "type", "userEmail", "userName", "groupName", "instanceId"],
[1, "user", "jake@grist.com", "Jake", "", ""],
[2, "instance", "", "", "", "i001"],
[3, "instance", "", "", "", "i002"],
[4, "user", "steve@grist.com", "Steve", "", ""],
[5, "instance", "", "", "", "i003"],
[6, "instance", "", "", "", "i004"],
])
self.assertTableData('_grist_ACLMemberships', cols="subset", data=[
["id", "parent", "child"],
[1, 1, 2],
[2, 1, 3],
[3, 4, 5],
[4, 1, 6]
])
# Remove a non-existent instance from a user
with self.assertRaisesRegexp(ValueError, "Cannot find existing instance id i000"):
self.apply_user_action(['RemoveInstance', 'i000'])
# Remove an instance from a user
out_actions = self.apply_user_action(['RemoveInstance', 'i002'])
self.assertPartialOutActions(out_actions, { "stored": [
["RemoveRecord", "_grist_ACLMemberships", 2],
["RemoveRecord", "_grist_ACLPrincipals", 3]
]})
self.assertTableData('_grist_ACLPrincipals', cols="subset", data=[
["id", "type", "userEmail", "userName", "groupName", "instanceId"],
[1, "user", "jake@grist.com", "Jake", "", ""],
[2, "instance", "", "", "", "i001"],
[4, "user", "steve@grist.com", "Steve", "", ""],
[5, "instance", "", "", "", "i003"],
[6, "instance", "", "", "", "i004"],
])
self.assertTableData('_grist_ACLMemberships', cols="subset", data=[
["id", "parent", "child"],
[1, 1, 2],
[3, 4, 5],
[4, 1, 6]
])
# Remove a non-existent user
with self.assertRaisesRegexp(ValueError, "Cannot find existing user with email null@grist.com"):
self.apply_user_action(['RemoveUser', 'null@grist.com'])
# Remove an existing user
out_actions = self.apply_user_action(['RemoveUser', 'jake@grist.com'])
self.assertPartialOutActions(out_actions, { "stored": [
["BulkRemoveRecord", "_grist_ACLMemberships", [1, 4]],
["BulkRemoveRecord", "_grist_ACLPrincipals", [2, 6, 1]]
]})
self.assertTableData('_grist_ACLPrincipals', cols="subset", data=[
["id", "type", "userEmail", "userName", "groupName", "instanceId"],
[4, "user", "steve@grist.com", "Steve", "", ""],
[5, "instance", "", "", "", "i003"],
])
self.assertTableData('_grist_ACLMemberships', cols="subset", data=[
["id", "parent", "child"],
[3, 4, 5]
])
# Remove the only instance of an existing user, removing that user
out_actions = self.apply_user_action(['RemoveInstance', 'i003'])
self.assertPartialOutActions(out_actions, { "stored": [
["RemoveRecord", "_grist_ACLMemberships", 3],
["BulkRemoveRecord", "_grist_ACLPrincipals", [4, 5]]
]})
self.assertTableData('_grist_ACLPrincipals', cols="subset", data=[
["id", "type", "userEmail", "userName", "groupName", "instanceId"]
])
self.assertTableData('_grist_ACLMemberships', cols="subset", data=[
["id", "parent", "child"]
])
#----------------------------------------------------------------------
def test_pages_remove(self):
# Test that orphan pages get fixed after removing a page

@ -195,6 +195,10 @@ class UserActions(object):
'timezone': timezone}))
# Set up initial ACL data.
# NOTE The special records below are not actually used. They were intended for obsolete ACL
# plans, and are kept here to ensure that old versions of Grist can still open newer or
# migrated documents. (At least as long as they don't actually include additional new ACL
# rules.)
self._do_doc_action(actions.BulkAddRecord("_grist_ACLPrincipals", [1,2,3,4], {
'type': ['group', 'group', 'group', 'group'],
'groupName': ['Owners', 'Admins', 'Editors', 'Viewers'],
@ -281,94 +285,6 @@ class UserActions(object):
# There doesn't seem any need to return the big array of ids.
self.doBulkAddOrReplace(table_id, row_ids, column_values, replace=True)
@useraction
def AddUser(self, email, name, instance_ids):
# Add the user and instances to the ACLPrincipals table
inst_count = len(instance_ids)
user_row_id = self.AddRecord('_grist_ACLPrincipals', None, {
"type": "user",
"userName": name,
"userEmail": email
})
inst_row_ids = self.BulkAddRecord('_grist_ACLPrincipals', [None] * inst_count, {
"type": ["instance"] * inst_count,
"instanceId": instance_ids
})
# Add the user to instance associations to the ACLMemberships table
row_ids = [None] * inst_count
parent_ids = [user_row_id] * inst_count
child_ids = inst_row_ids[:]
# If we have an Owners group (as every document does, thanks to InitNewDoc), add the user to
# it. (This is until we add interfaces to manage groups and ACLs properly.)
acl_principals = self._docmodel.get_table('_grist_ACLPrincipals')
owners_ref = acl_principals.lookupOne(type='group', groupName='Owners')
if owners_ref:
row_ids.append(None)
parent_ids.append(owners_ref)
child_ids.append(user_row_id)
self.BulkAddRecord('_grist_ACLMemberships', row_ids, {"parent": parent_ids, "child": child_ids})
# TODO: This is currently unused.
@useraction
def RemoveUser(self, email):
# Remove the user from the ACLPrincipals table
# Fetch the tables needed for lookup
acl_principals = self._docmodel.get_table('_grist_ACLPrincipals')
acl_memberships_table = self._docmodel.get_table('_grist_ACLMemberships')
# Lookup the user principal and their child instances
user = acl_principals.lookupOne(userEmail=email)
if user.id == 0:
raise ValueError("Cannot find existing user with email %s" % email)
self._docmodel.remove(list(user.memberships) + list(user.children) + [user])
# TODO: This is currently unused.
@useraction
def AddInstance(self, email, instance_id):
# Add the instance to an existing user in the ACLPrincipals table
# Fetch the tables needed for lookup
acl_principals = self._docmodel.get_table('_grist_ACLPrincipals')
# Lookup the user principal
user = acl_principals.lookupOne(userEmail=email)
if user.id == 0:
raise ValueError("Cannot find existing user with email %s" % email)
# Add the instance to the principals table and the association to the memberships table
row_id = self.AddRecord('_grist_ACLPrincipals', None, {
"type": "instance",
"instanceId": instance_id
})
self.AddRecord('_grist_ACLMemberships', None, {
"parent": int(user),
"child": row_id
})
@useraction
def RemoveInstance(self, instance_id):
# Remove instance to the ACLPrincipals table
# Fetch the tables needed for lookup
acl_principals = self._docmodel.get_table('_grist_ACLPrincipals')
acl_memberships_table = self._docmodel.get_table('_grist_ACLMemberships')
# Lookup the instance principal and the parent user from memberships
instance = acl_principals.lookupOne(instanceId=instance_id)
if instance.id == 0:
raise ValueError("Cannot find existing instance id %s" % instance_id)
inst_membership = acl_memberships_table.lookupOne(child=instance.id)
# Check how many user memberships exist
memberships = acl_memberships_table.lookupRecords(parent=inst_membership.parent.id)
if len(memberships) > 1:
# Only the instance must be removed
self.doBulkRemoveRecord('_grist_ACLMemberships', [inst_membership])
self.doBulkRemoveRecord('_grist_ACLPrincipals', [instance])
else:
# This is the last user instance - the user will also be removed. Remove the memberships
# first or they will be auto-updated to 0.
parent = inst_membership.parent
child = inst_membership.child
self.doBulkRemoveRecord('_grist_ACLMemberships', memberships)
self.doBulkRemoveRecord('_grist_ACLPrincipals', [parent, child])
def doBulkAddOrReplace(self, table_id, row_ids, column_values, replace=False):
table = self._engine.tables[table_id]
next_row_id = 1 if replace else table.next_row_id()

Loading…
Cancel
Save