diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 56f4ec83..d6eb87d9 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -790,8 +790,7 @@ export class ActiveDoc extends EventEmitter { } public async removeInstanceFromDoc(docSession: DocSession): Promise { - const instanceId = await this._sharing.removeInstanceFromDoc(); - await this._applyUserActions(docSession, [['RemoveInstance', instanceId]]); + await this._sharing.removeInstanceFromDoc(); } public async renameDocTo(docSession: OptDocSession, newName: string): Promise { diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index 7d5cda82..cde42594 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -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', ]); diff --git a/sandbox/grist/acl.py b/sandbox/grist/acl.py index 9ede8535..20b4bc44 100644 --- a/sandbox/grist/acl.py +++ b/sandbox/grist/acl.py @@ -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) +# 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]) -# 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 - - 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): +def acl_read_split(action_group): """ - 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. + 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. """ - 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 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 diff --git a/sandbox/grist/docmodel.py b/sandbox/grist/docmodel.py index 4ddc6900..67facb19 100644 --- a/sandbox/grist/docmodel.py +++ b/sandbox/grist/docmodel.py @@ -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): diff --git a/sandbox/grist/engine.py b/sandbox/grist/engine.py index 2ca20b2c..7c3a4487 100644 --- a/sandbox/grist/engine.py +++ b/sandbox/grist/engine.py @@ -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): """ diff --git a/sandbox/grist/schema.py b/sandbox/grist/schema.py index b24bfbd9..7edfc13b 100644 --- a/sandbox/grist/schema.py +++ b/sandbox/grist/schema.py @@ -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. - # ]), ] diff --git a/sandbox/grist/test_acl.py b/sandbox/grist/test_acl.py deleted file mode 100644 index 8850d99a..00000000 --- a/sandbox/grist/test_acl.py +++ /dev/null @@ -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']) diff --git a/sandbox/grist/test_useractions.py b/sandbox/grist/test_useractions.py index 757a4af8..d2b2227a 100644 --- a/sandbox/grist/test_useractions.py +++ b/sandbox/grist/test_useractions.py @@ -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 diff --git a/sandbox/grist/useractions.py b/sandbox/grist/useractions.py index 5e3f2f4a..ef14ae81 100644 --- a/sandbox/grist/useractions.py +++ b/sandbox/grist/useractions.py @@ -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()