gristlabs_grist-core/app/server/lib/GranularAccess.ts

3721 lines
152 KiB
TypeScript
Raw Normal View History

import { ALL_PERMISSION_PROPS } from 'app/common/ACLPermissions';
import { ACLRuleCollection, SPECIAL_RULES_TABLE_ID } from 'app/common/ACLRuleCollection';
import { ActionGroup } from 'app/common/ActionGroup';
import { createEmptyActionSummary } from 'app/common/ActionSummary';
import { ApplyUAExtendedOptions, ServerQuery } from 'app/common/ActiveDocAPI';
import { ApiError } from 'app/common/ApiError';
import { MapWithTTL } from 'app/common/AsyncCreate';
import {
AddRecord,
BulkAddRecord,
BulkColValues,
BulkRemoveRecord,
BulkUpdateRecord,
getColValues,
isBulkAddRecord,
isBulkRemoveRecord,
isBulkUpdateRecord,
isUpdateRecord,
} from 'app/common/DocActions';
import { AttachmentColumns, gatherAttachmentIds, getAttachmentColumns } from 'app/common/AttachmentColumns';
import { RemoveRecord, ReplaceTableData, UpdateRecord } from 'app/common/DocActions';
import { CellValue, ColValues, DocAction, getTableId, isSchemaAction } from 'app/common/DocActions';
import { getColIdsFromDocAction, TableDataAction, UserAction } from 'app/common/DocActions';
import { DocData } from 'app/common/DocData';
import { UserOverride } from 'app/common/DocListAPI';
import { DocUsageSummary, FilteredDocUsageSummary } from 'app/common/DocUsage';
import { normalizeEmail } from 'app/common/emails';
import { ErrorWithCode } from 'app/common/ErrorWithCode';
import { InfoEditor } from 'app/common/GranularAccessClause';
import * as gristTypes from 'app/common/gristTypes';
import { getSetMapValue, isNonNullish, pruneArray } from 'app/common/gutil';
import { compilePredicateFormula, PredicateFormulaInput } from 'app/common/PredicateFormula';
import { MetaRowRecord, SingleCell } from 'app/common/TableData';
import { EmptyRecordView, InfoView, RecordView } from 'app/common/RecordView';
import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
import { User } from 'app/common/User';
import { FullUser, UserAccessData } from 'app/common/UserAPI';
import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
import { GristObjCode } from 'app/plugin/GristData';
import { DocClients } from 'app/server/lib/DocClients';
(core) add initial support for special shares Summary: This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules. It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with: * When reading rules, if there are shares, extra rules are added. * If there are shares, all rules are made conditional on a "ShareRef" user property. * "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share. There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do: ``` gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}]) ``` If you look at the home db now there should be something in the `shares` table: ``` $ sqlite3 -table landing.db "select * from shares" +----+------------------------+------------------------+--------------+---------+ | id | key | doc_id | link_id | options | +----+------------------------+------------------------+--------------+---------+ | 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... | +----+------------------------+------------------------+--------------+---------+ ``` If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share. E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc. To actually share some material - useful commands: ``` gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}]) gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}]) ``` For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options. I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard. Test Plan: tests added Reviewers: dsagal, georgegevoian Reviewed By: dsagal, georgegevoian Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
import { getDocSessionAccess, getDocSessionAltSessionId, getDocSessionShare,
getDocSessionUser, OptDocSession } from 'app/server/lib/DocSession';
import { DocStorage, REMOVE_UNUSED_ATTACHMENTS_DELAY } from 'app/server/lib/DocStorage';
import log from 'app/server/lib/log';
import { IPermissionInfo, MixedPermissionSetWithContext,
PermissionInfo, PermissionSetWithContext } from 'app/server/lib/PermissionInfo';
import { TablePermissionSetWithContext } from 'app/server/lib/PermissionInfo';
import { integerParam } from 'app/server/lib/requestUtils';
import { getRelatedRows, getRowIdsFromDocAction } from 'app/server/lib/RowAccess';
import cloneDeep = require('lodash/cloneDeep');
import fromPairs = require('lodash/fromPairs');
import get = require('lodash/get');
import memoize = require('lodash/memoize');
// tslint:disable:no-bitwise
// Actions that add/update/remove/replace rows (DocActions only - UserActions
// may also result in row changes but are not in this list).
const ACTION_WITH_TABLE_ID = new Set(['AddRecord', 'BulkAddRecord', 'UpdateRecord', 'BulkUpdateRecord',
'RemoveRecord', 'BulkRemoveRecord',
'ReplaceTableData', 'TableData',
]);
type DataAction = AddRecord | BulkAddRecord | UpdateRecord | BulkUpdateRecord |
RemoveRecord | BulkRemoveRecord | ReplaceTableData | TableDataAction;
// Check if action adds/updates/removes/replaces rows.
function isDataAction(a: UserAction): a is DataAction {
return ACTION_WITH_TABLE_ID.has(String(a[0]));
}
function isAddRecordAction(a: DataAction): a is AddRecord | BulkAddRecord {
return ['AddRecord', 'BulkAddRecord'].includes(a[0]);
}
function isRemoveRecordAction(a: DataAction): a is RemoveRecord | BulkRemoveRecord {
return ['RemoveRecord', 'BulkRemoveRecord'].includes(a[0]);
}
function isBulkAction(a: DataAction): a is BulkAddRecord | BulkUpdateRecord |
BulkRemoveRecord | ReplaceTableData | TableDataAction {
return Array.isArray(a[2]);
}
// Check if a tableId is that of an ACL table. Currently just _grist_ACLRules and
// _grist_ACLResources are accepted.
function isAclTable(tableId: string): boolean {
return ['_grist_ACLRules', '_grist_ACLResources'].includes(tableId);
}
const ADD_OR_UPDATE_RECORD_ACTIONS = ['AddOrUpdateRecord', 'BulkAddOrUpdateRecord'];
function isAddOrUpdateRecordAction([actionName]: UserAction): boolean {
return ADD_OR_UPDATE_RECORD_ACTIONS.includes(String(actionName));
(core) Add BulkAddOrUpdateRecord action for efficiency Summary: This diff adds a new `BulkAddOrUpdateRecord` user action which is what is sounds like: - A bulk version of the existing `AddOrUpdateRecord` action. - Much more efficient for operating on many records than applying many individual actions. - Column values are specified as maps from `colId` to arrays of values as usual. - Produces bulk versions of `AddRecord` and `UpdateRecord` actions instead of many individual actions. Examples of users wanting to use something like `AddOrUpdateRecord` with large numbers of records: - https://grist.slack.com/archives/C0234CPPXPA/p1651789710290879 - https://grist.slack.com/archives/C0234CPPXPA/p1660743493480119 - https://grist.slack.com/archives/C0234CPPXPA/p1660333148491559 - https://grist.slack.com/archives/C0234CPPXPA/p1663069291726159 I tested what made many `AddOrUpdateRecord` actions slow in the first place. It was almost entirely due to producing many individual `AddRecord` user actions. About half of that time was for processing the resulting `AddRecord` doc actions. Lookups and updates were not a problem. With these changes, the slowness is gone. The Python user action implementation is more complex but there are no surprises. The JS API now groups `records` based on the keys of `require` and `fields` so that `BulkAddOrUpdateRecord` can be applied to each group. Test Plan: Update and extend Python and DocApi tests. Reviewers: jarek, paulfitz Reviewed By: jarek, paulfitz Subscribers: jarek Differential Revision: https://phab.getgrist.com/D3642
2022-09-28 13:13:07 +00:00
}
// A list of key metadata tables that need special handling. Other metadata tables may
// refer to material in some of these tables but don't need special handling.
// TODO: there are other metadata tables that would need access control, or redesign -
// specifically _grist_Attachments.
const STRUCTURAL_TABLES = new Set(['_grist_Tables', '_grist_Tables_column', '_grist_Views',
'_grist_Views_section', '_grist_Views_section_field',
(core) add initial support for special shares Summary: This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules. It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with: * When reading rules, if there are shares, extra rules are added. * If there are shares, all rules are made conditional on a "ShareRef" user property. * "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share. There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do: ``` gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}]) ``` If you look at the home db now there should be something in the `shares` table: ``` $ sqlite3 -table landing.db "select * from shares" +----+------------------------+------------------------+--------------+---------+ | id | key | doc_id | link_id | options | +----+------------------------+------------------------+--------------+---------+ | 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... | +----+------------------------+------------------------+--------------+---------+ ``` If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share. E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc. To actually share some material - useful commands: ``` gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}]) gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}]) ``` For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options. I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard. Test Plan: tests added Reviewers: dsagal, georgegevoian Reviewed By: dsagal, georgegevoian Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
'_grist_ACLResources', '_grist_ACLRules',
'_grist_Shares']);
// Actions that won't be allowed (yet) for a user with nuanced access to a document.
// A few may be innocuous, but that hasn't been figured out yet.
const SPECIAL_ACTIONS = new Set(['InitNewDoc',
'EvalCode',
'UpdateSummaryViewSection',
'DetachSummaryViewSection',
'GenImporterView',
'MakeImportTransformColumns',
'FillTransformRuleColIds',
'TransformAndFinishImport',
'AddView',
'AddHiddenColumn',
'RespondToRequests',
]);
// Odd-ball actions marked as deprecated or which seem unlikely to be used.
const SURPRISING_ACTIONS = new Set([
'RemoveView',
'AddViewSection',
]);
// Actions we'll allow unconditionally for now.
const OK_ACTIONS = new Set(['Calculate', 'UpdateCurrentTime']);
// Other actions that are believed to be compatible with granular access.
// Only add an action to OTHER_RECOGNIZED_ACTIONS if you know access control
// has been handled for it, or it is clear that access control can be done
// by looking at the Create/Update/Delete permissions for the DocActions it
// will create.
const OTHER_RECOGNIZED_ACTIONS = new Set([
// Data actions.
'AddRecord',
'BulkAddRecord',
'UpdateRecord',
'BulkUpdateRecord',
'RemoveRecord',
'BulkRemoveRecord',
'ReplaceTableData',
(core) Add BulkAddOrUpdateRecord action for efficiency Summary: This diff adds a new `BulkAddOrUpdateRecord` user action which is what is sounds like: - A bulk version of the existing `AddOrUpdateRecord` action. - Much more efficient for operating on many records than applying many individual actions. - Column values are specified as maps from `colId` to arrays of values as usual. - Produces bulk versions of `AddRecord` and `UpdateRecord` actions instead of many individual actions. Examples of users wanting to use something like `AddOrUpdateRecord` with large numbers of records: - https://grist.slack.com/archives/C0234CPPXPA/p1651789710290879 - https://grist.slack.com/archives/C0234CPPXPA/p1660743493480119 - https://grist.slack.com/archives/C0234CPPXPA/p1660333148491559 - https://grist.slack.com/archives/C0234CPPXPA/p1663069291726159 I tested what made many `AddOrUpdateRecord` actions slow in the first place. It was almost entirely due to producing many individual `AddRecord` user actions. About half of that time was for processing the resulting `AddRecord` doc actions. Lookups and updates were not a problem. With these changes, the slowness is gone. The Python user action implementation is more complex but there are no surprises. The JS API now groups `records` based on the keys of `require` and `fields` so that `BulkAddOrUpdateRecord` can be applied to each group. Test Plan: Update and extend Python and DocApi tests. Reviewers: jarek, paulfitz Reviewed By: jarek, paulfitz Subscribers: jarek Differential Revision: https://phab.getgrist.com/D3642
2022-09-28 13:13:07 +00:00
// Data actions handled specially because of read needs.
'AddOrUpdateRecord',
(core) Add BulkAddOrUpdateRecord action for efficiency Summary: This diff adds a new `BulkAddOrUpdateRecord` user action which is what is sounds like: - A bulk version of the existing `AddOrUpdateRecord` action. - Much more efficient for operating on many records than applying many individual actions. - Column values are specified as maps from `colId` to arrays of values as usual. - Produces bulk versions of `AddRecord` and `UpdateRecord` actions instead of many individual actions. Examples of users wanting to use something like `AddOrUpdateRecord` with large numbers of records: - https://grist.slack.com/archives/C0234CPPXPA/p1651789710290879 - https://grist.slack.com/archives/C0234CPPXPA/p1660743493480119 - https://grist.slack.com/archives/C0234CPPXPA/p1660333148491559 - https://grist.slack.com/archives/C0234CPPXPA/p1663069291726159 I tested what made many `AddOrUpdateRecord` actions slow in the first place. It was almost entirely due to producing many individual `AddRecord` user actions. About half of that time was for processing the resulting `AddRecord` doc actions. Lookups and updates were not a problem. With these changes, the slowness is gone. The Python user action implementation is more complex but there are no surprises. The JS API now groups `records` based on the keys of `require` and `fields` so that `BulkAddOrUpdateRecord` can be applied to each group. Test Plan: Update and extend Python and DocApi tests. Reviewers: jarek, paulfitz Reviewed By: jarek, paulfitz Subscribers: jarek Differential Revision: https://phab.getgrist.com/D3642
2022-09-28 13:13:07 +00:00
'BulkAddOrUpdateRecord',
// Certain column actions are handled specially because of reads that
// don't fit the pattern of data actions.
'ConvertFromColumn',
'CopyFromColumn',
// Groups of actions.
'ApplyDocActions',
'ApplyUndoActions',
// Column-level schema changes.
'AddColumn',
'AddVisibleColumn',
'RemoveColumn',
'RenameColumn',
'ModifyColumn',
// Table-level schema changes.
'AddEmptyTable',
'AddTable',
'AddRawTable',
'RemoveTable',
'RenameTable',
// A schema action handled specially because of read needs.
'DuplicateTable',
// Display column support.
'SetDisplayFormula',
'MaybeCopyDisplayFormula',
// Sundry misc.
'RenameChoices',
'AddEmptyRule',
'CreateViewSection',
'RemoveViewSection',
]);
// When an attachment is uploaded, it isn't immediately added to a cell in
// the document. We grant the uploader a special period where they can freely
// add or re-add the attachment to the document without access control fuss.
// We keep that period within the time range where an unused attachment
// would get deleted.
const UPLOADED_ATTACHMENT_OWNERSHIP_PERIOD =
(REMOVE_UNUSED_ATTACHMENTS_DELAY.delayMs - REMOVE_UNUSED_ATTACHMENTS_DELAY.varianceMs) / 2;
// When a user undoes their own action or actions, checks of attachment ownership
// are handled specially. This special handling will not apply for undoes of actions
// older than this limit.
const HISTORICAL_ATTACHMENT_OWNERSHIP_PERIOD = 24 * 60 * 60 * 1000;
// Transform columns are special. In case we have some rules defined they are only visible
// to those with SCHEMA_EDIT permission.
const TRANSFORM_COLUMN_PREFIXES = ['gristHelper_Converted', 'gristHelper_Transform'];
/**
* Checks if this is a special helper column used during type conversion.
*/
function isTransformColumn(colId: string): boolean {
return TRANSFORM_COLUMN_PREFIXES.some(prefix => colId.startsWith(prefix));
}
interface DocUpdateMessage {
actionGroup: ActionGroup;
docActions: DocAction[];
docUsage: DocUsageSummary;
}
/**
* Granular access for a single bundle, in different phases.
*/
export interface GranularAccessForBundle {
canApplyBundle(): Promise<void>;
appliedBundle(): Promise<void>;
finishedBundle(): Promise<void>;
sendDocUpdateForBundle(actionGroup: ActionGroup, docUsage: DocUsageSummary): Promise<void>;
}
/**
*
* Manage granular access to a document. This allows nuances other than the coarse
* owners/editors/viewers distinctions. Nuances are stored in the _grist_ACLResources
* and _grist_ACLRules tables.
*
* When the document is being modified, the object's GranularAccess is called at various
* steps of the process to check access rights. The GranularAccess object stores some
* state for an in-progress modification, to allow some caching of calculations across
* steps and clients. We expect modifications to be serialized, and the following
* pattern of calls for modifications:
*
* - assertCanMaybeApplyUserActions(), called with UserActions for an initial access check.
* Since not all checks can be done without analyzing UserActions into DocActions,
* it is ok for this call to pass even if a more definitive test later will fail.
* - getGranularAccessForBundle(), called once a possible bundle has been prepared
* (the UserAction has been compiled to DocActions).
* - canApplyBundle(), called when DocActions have been produced from UserActions,
* but before those DocActions have been applied to the DB. If fails, the modification
(core) add initial support for special shares Summary: This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules. It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with: * When reading rules, if there are shares, extra rules are added. * If there are shares, all rules are made conditional on a "ShareRef" user property. * "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share. There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do: ``` gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}]) ``` If you look at the home db now there should be something in the `shares` table: ``` $ sqlite3 -table landing.db "select * from shares" +----+------------------------+------------------------+--------------+---------+ | id | key | doc_id | link_id | options | +----+------------------------+------------------------+--------------+---------+ | 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... | +----+------------------------+------------------------+--------------+---------+ ``` If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share. E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc. To actually share some material - useful commands: ``` gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}]) gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}]) ``` For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options. I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard. Test Plan: tests added Reviewers: dsagal, georgegevoian Reviewed By: dsagal, georgegevoian Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
* will be abandoned. This method will also finalize some bundle state,
* specifically the `maybeHasShareChanges` flag.
* - appliedBundle(), called when DocActions have been applied to the DB, but before
* those changes have been sent to clients.
* - sendDocUpdateForBundle() is called once a bundle has been applied, to notify
* client of changes.
* - finishedBundle(), called when completely done with modification and any needed
* client notifications, whether successful or failed.
*
*
*/
export class GranularAccess implements GranularAccessForBundle {
// The collection of all rules.
private _ruler = new Ruler(this);
// Cache of user attributes associated with the given docSession. It's a WeakMap, to allow
// garbage-collection once docSession is no longer in use.
private _userAttributesMap = new WeakMap<OptDocSession, UserAttributes>();
private _prevUserAttributesMap: WeakMap<OptDocSession, UserAttributes>|undefined;
private _attachmentUploads = new MapWithTTL<number, string>(UPLOADED_ATTACHMENT_OWNERSHIP_PERIOD);
// When broadcasting a sequence of DocAction[]s, this contains the state of
// affected rows for the relevant table before and after each DocAction. It
// may contain some unaffected rows as well.
private _steps: Promise<ActionStep[]>|null = null;
// Intermediate metadata and rule state, if needed.
private _metaSteps: Promise<MetaStep[]>|null = null;
// Access control is done sequentially, bundle by bundle. This is the current bundle.
private _activeBundle: {
docSession: OptDocSession,
userActions: UserAction[],
docActions: DocAction[],
isDirect: boolean[],
undo: DocAction[],
// Flag tracking whether a set of actions have been applied to the database or not.
applied: boolean,
// Flag for whether user actions mention a rule change (clients are asked to reload
// in this case).
hasDeliberateRuleChange: boolean,
// Flag for whether doc actions mention a rule change, even if passive due to
// schema changes.
hasAnyRuleChange: boolean,
(core) add initial support for special shares Summary: This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules. It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with: * When reading rules, if there are shares, extra rules are added. * If there are shares, all rules are made conditional on a "ShareRef" user property. * "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share. There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do: ``` gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}]) ``` If you look at the home db now there should be something in the `shares` table: ``` $ sqlite3 -table landing.db "select * from shares" +----+------------------------+------------------------+--------------+---------+ | id | key | doc_id | link_id | options | +----+------------------------+------------------------+--------------+---------+ | 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... | +----+------------------------+------------------------+--------------+---------+ ``` If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share. E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc. To actually share some material - useful commands: ``` gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}]) gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}]) ``` For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options. I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard. Test Plan: tests added Reviewers: dsagal, georgegevoian Reviewed By: dsagal, georgegevoian Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
maybeHasShareChanges: boolean,
options: ApplyUAExtendedOptions|null,
(core) add initial support for special shares Summary: This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules. It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with: * When reading rules, if there are shares, extra rules are added. * If there are shares, all rules are made conditional on a "ShareRef" user property. * "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share. There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do: ``` gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}]) ``` If you look at the home db now there should be something in the `shares` table: ``` $ sqlite3 -table landing.db "select * from shares" +----+------------------------+------------------------+--------------+---------+ | id | key | doc_id | link_id | options | +----+------------------------+------------------------+--------------+---------+ | 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... | +----+------------------------+------------------------+--------------+---------+ ``` If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share. E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc. To actually share some material - useful commands: ``` gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}]) gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}]) ``` For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options. I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard. Test Plan: tests added Reviewers: dsagal, georgegevoian Reviewed By: dsagal, georgegevoian Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
shareRef?: number;
}|null;
public constructor(
private _docData: DocData,
private _docStorage: DocStorage,
private _docClients: DocClients,
private _fetchQueryFromDB: (query: ServerQuery) => Promise<TableDataAction>,
private _recoveryMode: boolean,
private _homeDbManager: HomeDBManager | null,
private _docId: string) {
}
public async close() {
this._attachmentUploads.clear();
}
public getGranularAccessForBundle(docSession: OptDocSession, docActions: DocAction[], undo: DocAction[],
userActions: UserAction[], isDirect: boolean[],
options: ApplyUAExtendedOptions|null): void {
if (this._activeBundle) { throw new Error('Cannot start a bundle while one is already in progress'); }
// This should never happen - attempts to write to a pre-fork session should be
// caught by an Authorizer. But let's be paranoid, since we may be pretending to
// be an owner for granular access purposes, and owners can write if we're not
// careful!
if (docSession.forkingAsOwner) { throw new Error('Should never modify a prefork'); }
this._activeBundle = {
docSession, docActions, undo, userActions, isDirect,
applied: false, hasDeliberateRuleChange: false, hasAnyRuleChange: false,
(core) add initial support for special shares Summary: This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules. It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with: * When reading rules, if there are shares, extra rules are added. * If there are shares, all rules are made conditional on a "ShareRef" user property. * "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share. There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do: ``` gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}]) ``` If you look at the home db now there should be something in the `shares` table: ``` $ sqlite3 -table landing.db "select * from shares" +----+------------------------+------------------------+--------------+---------+ | id | key | doc_id | link_id | options | +----+------------------------+------------------------+--------------+---------+ | 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... | +----+------------------------+------------------------+--------------+---------+ ``` If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share. E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc. To actually share some material - useful commands: ``` gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}]) gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}]) ``` For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options. I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard. Test Plan: tests added Reviewers: dsagal, georgegevoian Reviewed By: dsagal, georgegevoian Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
maybeHasShareChanges: false,
options,
};
this._activeBundle.hasDeliberateRuleChange =
scanActionsRecursively(userActions, (a) => isAclTable(String(a[1])));
this._activeBundle.hasAnyRuleChange =
scanActionsRecursively(docActions, a => actionHasRuleChange(a));
}
/**
* Update granular access from DocData.
*/
(core) support adding user characteristic tables for granular ACLs Summary: This is a prototype for expanding the conditions that can be used in granular ACLs. When processing ACLs, the following variables (called "characteristics") are now available in conditions: * UserID * Email * Name * Access (owners, editors, viewers) The set of variables can be expanded by adding a "characteristic" clause. This is a clause which specifies: * A tableId * The name of an existing characteristic * A colId The effect of the clause is to expand the available characteristics with all the columns in the table, with values taken from the record where there is a match between the specified characteristic and the specified column. Existing clauses are generalized somewhat to demonstrate and test the use these variables. That isn't the main point of this diff though, and I propose to leave generalizing+systematizing those clauses for a future diff. Issues I'm not dealing with here: * How clauses combine. (The scope on GranularAccessRowClause is a hack to save me worrying about that yet). * The full set of matching methods we'll allow. * Refreshing row access in clients when the tables mentioned in characteristic tables change. * Full CRUD permission control. * Default rules (part of combination). * Reporting errors in access rules. That said, with this diff it is possible to e.g. assign a City to editors by their email address or name, and have only rows for those Cities be visible in their client. Ability to modify those rows, and remain updates about them, remains under incomplete control. Test Plan: added tests Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2642
2020-10-19 14:25:21 +00:00
public async update() {
await this._ruler.update(this._docData);
// Also clear the per-docSession cache of user attributes.
this._userAttributesMap = new WeakMap();
}
/**
* Construct the UserInfo needed for evaluating rules. This also enriches the user with values
* created by user-attribute rules.
*/
public async getUser(docSession: OptDocSession): Promise<User> {
const linkParameters = docSession.authorizer?.getLinkParameters() || {};
let access: Role | null;
let fullUser: FullUser | null;
const attrs = this._getUserAttributes(docSession);
access = getDocSessionAccess(docSession);
const linkId = getDocSessionShare(docSession);
let shareRef: number = 0;
if (linkId) {
const rowIds = this._docData.getMetaTable('_grist_Shares').filterRowIds({
linkId,
});
if (rowIds.length > 1) {
throw new Error('Share identifier is not unique');
}
if (rowIds.length === 1) {
shareRef = rowIds[0];
}
}
if (docSession.forkingAsOwner) {
// For granular access purposes, we become an owner.
// It is a bit of a bluff, done on the understanding that this session will
// never be used to edit the document, and that any edits will be done on a
// fork.
access = 'owners';
}
// If aclAsUserId/aclAsUser is set, then override user for acl purposes.
if (linkParameters.aclAsUserId || linkParameters.aclAsUser) {
if (access !== 'owners') { throw new ErrorWithCode('ACL_DENY', 'only an owner can override user'); }
if (attrs.override) {
// Used cached properties.
access = attrs.override.access;
fullUser = attrs.override.user;
} else {
attrs.override = await this._getViewAsUser(linkParameters);
fullUser = attrs.override.user;
}
} else {
fullUser = getDocSessionUser(docSession);
}
const user = new User();
user.Access = access;
user.ShareRef = shareRef || null;
const isAnonymous = fullUser?.id === this._homeDbManager?.getAnonymousUserId() ||
fullUser?.id === null;
user.UserID = (!isAnonymous && fullUser?.id) || null;
user.Email = fullUser?.email || null;
user.Name = fullUser?.name || null;
// If viewed from a websocket, collect any link parameters included.
// TODO: could also get this from rest api access, just via a different route.
user.LinkKey = linkParameters;
// Include origin info if accessed via the rest api.
// TODO: could also get this for websocket access, just via a different route.
user.Origin = docSession.req?.get('origin') || null;
user.SessionID = isAnonymous ? `a${getDocSessionAltSessionId(docSession)}` : `u${user.UserID}`;
user.IsLoggedIn = !isAnonymous;
user.UserRef = fullUser?.ref || null; // Empty string should be treated as null.
if (this._ruler.ruleCollection.ruleError && !this._recoveryMode) {
// It is important to signal that the doc is in an unexpected state,
// and prevent it opening.
throw this._ruler.ruleCollection.ruleError;
}
for (const clause of this._ruler.ruleCollection.getUserAttributeRules().values()) {
if (clause.name in user) {
log.warn(`User attribute ${clause.name} ignored; conflicts with an existing one`);
continue;
}
if (attrs.rows[clause.name]) {
user[clause.name] = attrs.rows[clause.name];
continue;
}
let rec = new EmptyRecordView();
let rows: TableDataAction|undefined;
try {
// Use lodash's get() that supports paths, e.g. charId of 'a.b' would look up `user.a.b`.
// TODO: add indexes to db.
rows = await this._fetchQueryFromDB({
tableId: clause.tableId,
filters: { [clause.lookupColId]: [get(user, clause.charId)] }
});
} catch (e) {
log.warn(`User attribute ${clause.name} failed`, e);
}
if (rows && rows[2].length > 0) { rec = new RecordView(rows, 0); }
user[clause.name] = rec;
attrs.rows[clause.name] = rec;
}
return user;
}
public async getCachedUser(docSession: OptDocSession): Promise<User> {
const access = await this._getAccess(docSession);
return access.getUser();
}
/**
* Represent fields from the session in an input object for ACL rules.
* Just one field currently, "user".
*/
public async inputs(docSession: OptDocSession): Promise<PredicateFormulaInput> {
return {
user: await this.getUser(docSession),
docId: this._docId
};
}
/**
* Check whether user has any access to table.
*/
public async hasTableAccess(docSession: OptDocSession, tableId: string) {
const pset = await this.getTableAccess(docSession, tableId);
return this.getReadPermission(pset) !== 'deny';
}
/**
* Checks if user has read access to a cell. Optionally takes docData that will be used
* to retrieve the cell value instead of the current docData.
*/
public async hasCellAccess(docSession: OptDocSession, cell: SingleCell, docData?: DocData): Promise<boolean> {
try {
await this.getCellValue(docSession, cell, docData);
return true;
} catch(err) {
if (err instanceof ErrorWithCode) { return false; }
throw err;
}
}
/**
* Get content of a given cell, if user has read access. Optionally takes docData that will be used
* to retrieve the cell value instead of the current docData.
* Throws if not.
*/
public async getCellValue(docSession: OptDocSession, cell: SingleCell, docData?: DocData): Promise<CellValue> {
function fail(): never {
throw new ErrorWithCode('ACL_DENY', 'Cannot access cell');
}
const hasExceptionalAccess = this._hasExceptionalFullAccess(docSession);
if (!hasExceptionalAccess && !await this.hasTableAccess(docSession, cell.tableId)) { fail(); }
let rows: TableDataAction|null = null;
if (docData) {
const record = docData.getTable(cell.tableId)?.getRecord(cell.rowId);
if (record) {
rows = ['TableData', cell.tableId, [cell.rowId], getColValues([record])];
}
} else {
rows = await this._fetchQueryFromDB({
tableId: cell.tableId,
filters: { id: [cell.rowId] }
});
}
if (!rows || rows[2].length === 0) {
return fail();
}
const rec = new RecordView(rows, 0);
if (!hasExceptionalAccess) {
const input: PredicateFormulaInput = {...await this.inputs(docSession), rec, newRec: rec};
const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);
const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;
if (rowAccess === 'deny') { fail(); }
if (rowAccess !== 'allow') {
const colAccess = rowPermInfo.getColumnAccess(cell.tableId, cell.colId).perms.read;
if (colAccess === 'deny') { fail(); }
}
const colValues = rows[3];
if (!(cell.colId in colValues)) { fail(); }
}
return rec.get(cell.colId);
}
/**
* Checks whether the specified cell is accessible by the user, and contains
* the specified attachment. Throws with ACL_DENY code if not.
*/
public async assertAttachmentAccess(docSession: OptDocSession, cell: SingleCell, attId: number): Promise<void> {
const value = await this.getCellValue(docSession, cell);
// Need to check column is actually an attachment column.
if (this._docStorage.getColumnType(cell.tableId, cell.colId) !== 'Attachments') {
throw new ErrorWithCode('ACL_DENY', 'not an attachment column');
}
// Check that material in cell includes the attachment.
if (!gristTypes.isList(value)) {
throw new ErrorWithCode('ACL_DENY', 'not a list');
}
if (value.indexOf(attId) <= 0) {
throw new ErrorWithCode('ACL_DENY', 'attachment not present in cell');
}
}
/**
* Check whether the specified attachment is known to have been uploaded
* by the user (identified by SessionID) recently.
*/
public async isAttachmentUploadedByUser(docSession: OptDocSession, attId: number): Promise<boolean> {
const user = await this.getUser(docSession);
const id = user.SessionID || '';
return (this._attachmentUploads.get(attId) === id);
}
/**
* Find a cell in an attachment column that contains the specified attachment,
* and which is accessible by the user associated with the session.
*/
public async findAttachmentCellForUser(docSession: OptDocSession, attId: number): Promise<SingleCell|undefined> {
// Find cells that refer to the given attachment.
const cells = await this._docStorage.findAttachmentReferences(attId);
// Run through them to see if the user has access to any of them.
// We'd expect in a typical document that this will be a small
// list of cells, typically 1 or less, but of course extreme cases
// are possible.
for (const possibleCell of cells) {
try {
await this.assertAttachmentAccess(docSession, possibleCell, attId);
return possibleCell;
} catch (e) {
if (e instanceof ErrorWithCode && e.code === 'ACL_DENY') {
continue;
}
throw e;
}
}
// Nothing found.
return undefined;
}
/**
* Called after UserAction[]s have been applied in the sandbox, and DocAction[]s have been
* computed, but before we have committed those DocAction[]s to the database. If this
* throws an exception, the sandbox changes will be reverted.
*/
public async canApplyBundle() {
if (!this._activeBundle) { throw new Error('no active bundle'); }
const {docActions, docSession, isDirect} = this._activeBundle;
const currentUser = await this.getUser(docSession);
const userIsOwner = await this.isOwner(docSession);
if (this._activeBundle.hasDeliberateRuleChange && !userIsOwner) {
throw new ErrorWithCode('ACL_DENY', 'Only owners can modify access rules');
}
// Normally, viewer requests would never reach this point, but they can happen
// using the "view as" functionality where user is an owner wanting to preview the
// access level of another. And again, the default access rules would normally
// forbid edit access to a viewer - but that can be overridden.
// An alternative to this check would be to sandwich user-defined access rules
// between some defaults. Currently the defaults have lower priority than
// user-defined access rules.
if (!canEdit(await this.getNominalAccess(docSession))) {
throw new ErrorWithCode('ACL_DENY', 'Only owners or editors can modify documents');
}
if (this._ruler.haveRules()) {
await Promise.all(
docActions.map((action, actionIdx) => {
if (isDirect[actionIdx]) {
return this._checkIncomingDocAction({docSession, action, actionIdx});
}
}));
(core) add initial support for special shares Summary: This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules. It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with: * When reading rules, if there are shares, extra rules are added. * If there are shares, all rules are made conditional on a "ShareRef" user property. * "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share. There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do: ``` gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}]) ``` If you look at the home db now there should be something in the `shares` table: ``` $ sqlite3 -table landing.db "select * from shares" +----+------------------------+------------------------+--------------+---------+ | id | key | doc_id | link_id | options | +----+------------------------+------------------------+--------------+---------+ | 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... | +----+------------------------+------------------------+--------------+---------+ ``` If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share. E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc. To actually share some material - useful commands: ``` gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}]) gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}]) ``` For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options. I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard. Test Plan: tests added Reviewers: dsagal, georgegevoian Reviewed By: dsagal, georgegevoian Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
const shares = this._docData.getMetaTable('_grist_Shares');
/**
* This is a good point at which to determine whether we may be
* making a change to special shares. If we may be, then currently
* we will reload any connected web clients accessing the document
* via a share.
*
* The role of the `maybeHasShareChanges` flag is to trigger
* reloads of web clients that are accessing the document via a
* share, if share configuration may have changed. It doesn't
* actually impact access control itself. The sketch of order of
* operations given in the docstring for the GranularAccess
* class is helpful for understanding this flow.
*
* At the time of writing, web client support for special shares
* is not an official feature - but it is super convenient for testing
* and will be important later.
*/
if (shares.getRowIds().length > 0 &&
docActions.some(
action => getTableId(action).startsWith('_grist'))) {
// TODO: could actually compare new rules with old rules and
// see if they've changed. Or could exclude some tables that
// could easily change without an impact on share rules,
// such as _grist_Attachments. Either improvement could
// greatly reduce unnecessary web client reloads for shares
// if that becomes an issue.
this._activeBundle.maybeHasShareChanges = true;
}
}
await this._canApplyCellActions(currentUser, userIsOwner);
if (this._recoveryMode) {
// Don't do any further checking in recovery mode.
return;
}
// If the actions change any rules, verify that we'll be able to handle the changed rules. If
// they are to cause an error, reject the action to avoid forcing user into recovery mode.
// WATCH OUT - this will trigger for "passive" changes caused by tableId/colId renames.
if (docActions.some(docAction => isAclTable(getTableId(docAction)))) {
// Create a tmpDocData with just the tables we care about, then update docActions to it.
const tmpDocData: DocData = new DocData(
(tableId) => { throw new Error("Unexpected DocData fetch"); }, {
_grist_Tables: this._docData.getMetaTable('_grist_Tables').getTableDataAction(),
_grist_Tables_column: this._docData.getMetaTable('_grist_Tables_column').getTableDataAction(),
_grist_ACLResources: this._docData.getMetaTable('_grist_ACLResources').getTableDataAction(),
_grist_ACLRules: this._docData.getMetaTable('_grist_ACLRules').getTableDataAction(),
(core) add initial support for special shares Summary: This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules. It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with: * When reading rules, if there are shares, extra rules are added. * If there are shares, all rules are made conditional on a "ShareRef" user property. * "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share. There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do: ``` gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}]) ``` If you look at the home db now there should be something in the `shares` table: ``` $ sqlite3 -table landing.db "select * from shares" +----+------------------------+------------------------+--------------+---------+ | id | key | doc_id | link_id | options | +----+------------------------+------------------------+--------------+---------+ | 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... | +----+------------------------+------------------------+--------------+---------+ ``` If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share. E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc. To actually share some material - useful commands: ``` gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}]) gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}]) ``` For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options. I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard. Test Plan: tests added Reviewers: dsagal, georgegevoian Reviewed By: dsagal, georgegevoian Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
_grist_Shares: this._docData.getMetaTable('_grist_Shares').getTableDataAction(),
// WATCH OUT - Shares may need more tables, check.
});
for (const da of docActions) {
tmpDocData.receiveAction(da);
}
// Use the post-actions data to process the rules collection, and throw error if that fails.
const ruleCollection = new ACLRuleCollection();
await ruleCollection.update(tmpDocData, {log, compile: compilePredicateFormula});
if (ruleCollection.ruleError) {
throw new ApiError(ruleCollection.ruleError.message, 400);
}
try {
ruleCollection.checkDocEntities(tmpDocData);
} catch (err) {
throw new ApiError(err.message, 400);
}
}
(core) add initial support for special shares Summary: This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules. It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with: * When reading rules, if there are shares, extra rules are added. * If there are shares, all rules are made conditional on a "ShareRef" user property. * "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share. There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do: ``` gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}]) ``` If you look at the home db now there should be something in the `shares` table: ``` $ sqlite3 -table landing.db "select * from shares" +----+------------------------+------------------------+--------------+---------+ | id | key | doc_id | link_id | options | +----+------------------------+------------------------+--------------+---------+ | 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... | +----+------------------------+------------------------+--------------+---------+ ``` If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share. E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc. To actually share some material - useful commands: ``` gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}]) gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}]) ``` For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options. I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard. Test Plan: tests added Reviewers: dsagal, georgegevoian Reviewed By: dsagal, georgegevoian Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
// TODO: any changes needed to this logic for shares?
}
/**
* This should be called after each action bundle has been applied to the database,
* but before the actions are broadcast to clients. It will set us up to be able
* to efficiently filter those broadcasts.
*
* We expect actions bundles for a document to be applied+broadcast serially (the
* broadcasts can be parallelized, but should complete before moving on to further
* document mutation).
*/
public async appliedBundle() {
if (!this._activeBundle) { throw new Error('no active bundle'); }
const {docActions} = this._activeBundle;
this._activeBundle.applied = true;
if (!this._ruler.haveRules()) { return; }
// Check if a table that affects user attributes has changed. If so, put current
// attributes aside for later comparison, and clear cache.
const attrs = new Set([...this._ruler.ruleCollection.getUserAttributeRules().values()].map(r => r.tableId));
const attrChange = docActions.some(docAction => attrs.has(getTableId(docAction)));
if (attrChange) {
this._prevUserAttributesMap = this._userAttributesMap;
this._userAttributesMap = new WeakMap();
}
// If there's a schema change, zap permission cache.
const schemaChange = docActions.some(docAction => isSchemaAction(docAction));
if (attrChange || schemaChange) {
this._ruler.clearCache();
}
}
/**
* This should be called once an action bundle has been broadcast to
* all clients (or the bundle has been denied). It will clean up
* any temporary state cached for filtering those broadcasts.
*/
public async finishedBundle() {
if (!this._activeBundle) { return; }
if (this._activeBundle.applied) {
const {docActions} = this._activeBundle;
await this._updateRules(docActions);
}
this._steps = null;
this._metaSteps = null;
this._prevUserAttributesMap = undefined;
this._activeBundle = null;
}
/**
* Filter DocActions to be sent to a client.
*/
public async filterOutgoingDocActions(docSession: OptDocSession, docActions: DocAction[]): Promise<DocAction[]> {
// If the user requested a rule change, trigger a reload.
if (this._activeBundle?.hasDeliberateRuleChange) {
// TODO: could avoid reloading in many cases, especially for an owner who has full
// document access.
throw new ErrorWithCode('NEED_RELOAD', 'document needs reload, access rules changed');
}
(core) add initial support for special shares Summary: This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules. It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with: * When reading rules, if there are shares, extra rules are added. * If there are shares, all rules are made conditional on a "ShareRef" user property. * "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share. There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do: ``` gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}]) ``` If you look at the home db now there should be something in the `shares` table: ``` $ sqlite3 -table landing.db "select * from shares" +----+------------------------+------------------------+--------------+---------+ | id | key | doc_id | link_id | options | +----+------------------------+------------------------+--------------+---------+ | 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... | +----+------------------------+------------------------+--------------+---------+ ``` If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share. E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc. To actually share some material - useful commands: ``` gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}]) gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}]) ``` For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options. I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard. Test Plan: tests added Reviewers: dsagal, georgegevoian Reviewed By: dsagal, georgegevoian Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
const linkId = getDocSessionShare(docSession);
if (linkId && this._activeBundle?.maybeHasShareChanges) {
throw new ErrorWithCode('NEED_RELOAD', 'document needs reload, share may have changed');
}
// Optimize case where there are no rules to enforce.
if (!this._ruler.haveRules()) { return docActions; }
// If user attributes have changed, trigger a reload.
await this._checkUserAttributes(docSession);
const actions = await Promise.all(
docActions.map((action, actionIdx) => this._filterOutgoingDocAction({docSession, action, actionIdx})));
let result = ([] as ActionCursor[]).concat(...actions);
result = await this._filterOutgoingAttachments(result);
return await this._filterOutgoingCellInfo(docSession, docActions,
result.map(a => a.action));
}
/**
* Filter an ActionGroup to be sent to a client.
*/
public async filterActionGroup(
docSession: OptDocSession,
actionGroup: ActionGroup,
options: {role?: Role | null} = {}
): Promise<ActionGroup> {
if (await this.allowActionGroup(docSession, actionGroup, options)) { return actionGroup; }
// For now, if there's any nuance at all, suppress the summary and description.
const result: ActionGroup = { ...actionGroup };
result.actionSummary = createEmptyActionSummary();
result.desc = '';
return result;
}
/**
* Check whether an ActionGroup can be sent to the client. TODO: in future, we'll want
2022-02-19 09:46:49 +00:00
* to filter acceptable parts of ActionGroup, rather than denying entirely.
*/
public async allowActionGroup(
docSession: OptDocSession,
_actionGroup: ActionGroup,
options: {role?: Role | null} = {}
): Promise<boolean> {
return this.canReadEverything(docSession, options);
}
/**
* Filter DocUsageSummary to be sent to a client.
*/
public async filterDocUsageSummary(
docSession: OptDocSession,
docUsage: DocUsageSummary,
options: {role?: Role | null} = {}
): Promise<FilteredDocUsageSummary> {
const result: FilteredDocUsageSummary = { ...docUsage };
// Owners can see everything all the time.
if (await this.isOwner(docSession)) {
return result;
}
const role = options.role ?? await this.getNominalAccess(docSession);
const hasEditRole = canEdit(role);
if (!hasEditRole) { result.dataLimitStatus = null; }
const hasFullReadAccess = await this.canReadEverything(docSession);
if (!hasEditRole || !hasFullReadAccess) {
result.rowCount = 'hidden';
result.dataSizeBytes = 'hidden';
result.attachmentsSizeBytes = 'hidden';
}
return result;
}
/**
* Check the list of UserActions, throwing if something not permitted is found.
* The data engine is the definitive interpreter of UserActions, but we do what
* we can, and then rely on analysis of DocActions produced by the data engine
* later to finish the job. Any actions that read data and expose it in some way
* need to be caught at this point, since that won't be evident in the DocActions.
* So far, we've been restricting the permitted combinations of UserActions when
* data is read to make access control tractable. Likewise, any actions that might
* result in running user code that would not eventually be permitted needs to be
* caught now, since by the time it hits the data engine it is too late.
*/
public async checkUserActions(docSession: OptDocSession, actions: UserAction[]): Promise<void> {
if (this._hasExceptionalFullAccess(docSession)) { return; }
// Checks are in no particular order.
await this._checkSimpleDataActions(docSession, actions);
await this._checkForSpecialOrSurprisingActions(docSession, actions);
await this._checkIfNeedsEarlySchemaPermission(docSession, actions);
await this._checkDuplicateTableAccess(docSession, actions);
await this._checkAddOrUpdateAccess(docSession, actions);
}
/**
* Called when it is permissible to partially fulfill the requested actions.
* Will remove forbidden actions in a very limited set of recognized circumstances.
* In fact, currently in only one circumstance:
*
* - If there is a single requested action, and it is an ApplyUndoActions.
* The goal being to let a user undo their action to the extent that it
* is possible to do so.
*
* In this case, the list of actions nested in ApplyUndoActions will be extracted,
* treated as DocActions, and filtered to remove any component parts (at action,
* column, row, or individual cell level) that would be forbidden.
*
* Beyond pure data changes, there are no heroics - any schema change will
* result in prefiltering being skipped.
*
* Any filtering done here is NOT a security measure, and the output should
* not be granted any level of automatic trust.
*/
public async prefilterUserActions(docSession: OptDocSession, actions: UserAction[],
options: ApplyUAExtendedOptions|null): Promise<UserAction[]> {
// Currently we only attempt prefiltering for an ApplyUndoActions.
if (actions.length !== 1) { return actions; }
const userAction = actions[0];
if (userAction[0] !== 'ApplyUndoActions') { return actions; }
// Ok, this is an undo. Unpack the requested undo actions. For a bona
// fide ApplyUndoActions, these would be doc actions generated by the
// data engine and stored in action history. But there is no actual
// restriction in how ApplyUndoActions could be generated. Security
// is enforced separately, so we don't need to be paranoid here.
const docActions = userAction[1] as DocAction[];
// Bail out if there is any hint of a schema change.
// TODO: may want to also bail if an action we'd need to filter would
// affect a row id used later in the bundle. Perhaps prefiltering
// should be restricted to bundles of updates only for that reason.
for (const action of docActions) {
if (!isDataAction(action) || getTableId(action).startsWith('_grist')) {
return actions;
}
}
// Run through a simulation of access control on these actions,
// retaining only permitted material.
const proposedActions: UserAction[] = [];
try {
// Establish our doc actions as the current context for access control.
// We don't have undo information for them, but don't need to because
// they have not been applied to the db. Treat all actions as "direct"
// since we could not trust claims of indirectness currently in
// any case (though we could rearrange to limit how undo actions are
// requested).
this.getGranularAccessForBundle(docSession, docActions, [], docActions,
docActions.map(() => true), options);
for (const [actionIdx, action] of docActions.entries()) {
// A single action might contain forbidden material at cell, row, column,
// or table level. Retaining permitted material may require refactoring the
// single action into a series of actions.
try {
await this._checkIncomingDocAction({docSession, action, actionIdx});
// Nothing forbidden! Keep this action unchanged.
proposedActions.push(action);
} catch (e) {
if (String(e.code) !== 'ACL_DENY') { throw e; }
const acts = await this._prefilterDocAction({docSession, action, actionIdx});
proposedActions.push(...acts);
// Presumably we've changed the action. Zap our cache of intermediate
// states, since it is stale now. TODO: reorganize cache to so can avoid wasting
// time repeating work unnecessarily. The cache was designed with all-or-nothing
// operations in mind, and is poorly suited to prefiltering.
// Note: the meaning of newRec is slippery in prefiltering, since it depends on
// state at the end of the bundle, but that state is unstable now.
// TODO look into prefiltering in cases using newRec in a many-action bundle.
this._steps = null;
this._metaSteps = null;
}
}
} finally {
await this.finishedBundle();
}
return [['ApplyUndoActions', proposedActions]];
}
/**
* For changes that could include Python formulas, check for schema access early.
*/
public needEarlySchemaPermission(a: UserAction|DocAction): boolean {
const name = a[0] as string;
if (name === 'ModifyColumn' || name === 'SetDisplayFormula' ||
// ConvertFromColumn and CopyFromColumn are hard to reason
// about, especially since they appear in bundles with other
// actions. We throw up our hands a bit here, and just make
// sure the user has schema permissions. Today, in Grist, that
// gives a lot of power. If this gets narrowed down in future,
// we'll have to rethink this.
name === 'ConvertFromColumn' || name === 'CopyFromColumn') {
return true;
} else if (isDataAction(a)) {
const tableId = getTableId(a);
if (tableId === '_grist_Tables_column' || tableId === '_grist_Validations') {
return true;
}
}
return false;
}
/**
* Check whether access is simple, or there are granular nuances that need to be
* worked through. Currently if there are no owner-only tables, then everyone's
* access is simple and without nuance.
*/
public async hasNuancedAccess(docSession: OptDocSession): Promise<boolean> {
if (!this._ruler.haveRules()) { return false; }
return !await this.hasFullAccess(docSession);
}
/**
* Check if user is explicitly permitted to download/copy document.
* They may be allowed to download in any case, see canCopyEverything.
*/
public async hasFullCopiesPermission(docSession: OptDocSession): Promise<boolean> {
const permInfo = await this._getAccess(docSession);
return permInfo.getColumnAccess(SPECIAL_RULES_TABLE_ID, 'FullCopies').perms.read === 'allow';
}
/**
* Check if user may view Access Rules.
*/
public async hasAccessRulesPermission(docSession: OptDocSession): Promise<boolean> {
const permInfo = await this._getAccess(docSession);
return permInfo.getColumnAccess(SPECIAL_RULES_TABLE_ID, 'AccessRules').perms.read === 'allow';
}
/**
* Check whether user can read everything in document. Checks both home-level and doc-level
* permissions.
*/
public async canReadEverything(
docSession: OptDocSession,
options: {role?: Role | null} = {}
): Promise<boolean> {
const access = options.role ?? await this.getNominalAccess(docSession);
if (!canView(access)) { return false; }
const permInfo = await this._getAccess(docSession);
return this.getReadPermission(permInfo.getFullAccess()) === 'allow';
}
/**
* Allow if user can read all data, or is an owner.
* Might be worth making a special permission.
* At the time of writing, used for:
* - findColFromValues
* - autocomplete
* - unfiltered access to attachment metadata
*/
public async canScanData(docSession: OptDocSession): Promise<boolean> {
return await this.isOwner(docSession) || await this.canReadEverything(docSession);
}
/**
* Check whether user can copy everything in document. Owners can always copy
* everything, even if there are rules that specify they cannot.
*
* There's a small wrinkle about access rules. The content
* of _grist_ACLRules and Resources are only send to clients that are owners,
* but could be copied by others by other means (e.g. download) as long as all
* tables or columns are readable. This seems ok (no private info involved),
* just a bit inconsistent.
*/
public async canCopyEverything(docSession: OptDocSession): Promise<boolean> {
return await this.hasFullCopiesPermission(docSession) ||
await this.canReadEverything(docSession);
}
/**
* Check whether user has full access to the document. Currently that is interpreted
* as equivalent owner-level access to the document.
* TODO: uses of this method should be checked to see if they can be fleshed out
* now we have more of the ACL implementation done.
*/
public hasFullAccess(docSession: OptDocSession): Promise<boolean> {
return this.isOwner(docSession);
}
/**
* Check whether user has owner-level access to the document.
*/
public async isOwner(docSession: OptDocSession): Promise<boolean> {
const access = await this.getNominalAccess(docSession);
return access === 'owners';
}
/**
*
* If the user does not have access to the full document, we need to filter out
* parts of the document metadata. For simplicity, we overwrite rather than
* filter for now, so that the overall structure remains consistent. We overwrite:
*
* - names, textual ids, formulas, and other textual options
* - foreign keys linking columns/views/sections back to a forbidden table
*
* On the client, a page with a blank name will be marked gracefully as unavailable.
*
* Some information leaks, for example the existence of private tables and how
* many columns they had, and something of the relationships between them. Long term,
* it could be better to zap rows entirely, and do the work of cleaning up any cross
* references to them.
*
*/
public async filterMetaTables(docSession: OptDocSession,
tables: {[key: string]: TableDataAction}): Promise<{[key: string]: TableDataAction}> {
// If user has right to read everything, return immediately.
if (await this.canReadEverything(docSession)) { return tables; }
// If we are going to modify metadata, make a copy.
tables = cloneDeep(tables);
// Prepare cell censorship information.
const cells = new CellData(this._docData).convertToCells(tables['_grist_Cells']);
let cellCensor: CellAccessHelper|undefined;
if (cells.length > 0) {
cellCensor = this._createCellAccess(docSession);
await cellCensor.calculate(cells);
}
const permInfo = await this._getAccess(docSession);
const censor = new CensorshipInfo(permInfo, this._ruler.ruleCollection, tables,
await this.hasAccessRulesPermission(docSession),
cellCensor);
if (cellCensor) {
censor.filter(tables["_grist_Cells"]);
}
for (const tableId of STRUCTURAL_TABLES) {
censor.apply(tables[tableId]);
}
if (await this.needAttachmentControl(docSession)) {
// Attachments? No attachments here (whistles innocently).
// Computing which attachments user has access to would require
// looking at entire document, which we don't want to do. So instead
// we'll be sending this info on a need-to-know basis later.
const attachments = tables['_grist_Attachments'];
attachments[2] = [];
Object.values(attachments[3]).forEach(values => {
values.length = 0;
});
}
return tables;
}
/**
* Distill the clauses for the given session and table, to figure out the
* access level and any row-level access functions needed.
*/
public async getTableAccess(docSession: OptDocSession, tableId: string): Promise<TablePermissionSetWithContext> {
if (this._hasExceptionalFullAccess(docSession)) {
return {
perms: {read: 'allow', create: 'allow', delete: 'allow', update: 'allow', schemaEdit: 'allow'},
ruleType: 'table',
getMemos() { throw new Error('never needed'); }
};
}
return (await this._getAccess(docSession)).getTableAccess(tableId);
}
/**
* Modify table data in place, removing any rows or columns to which access
* is not granted.
*/
public async filterData(docSession: OptDocSession, data: TableDataAction) {
const permInfo = await this._getAccess(docSession);
const cursor: ActionCursor = {docSession, action: data, actionIdx: null};
const tableId = getTableId(data);
if (this.getReadPermission(permInfo.getTableAccess(tableId)) === 'mixed') {
const readAccessCheck = this._readAccessCheck(docSession);
await this._filterRowsAndCells(cursor, data, data, readAccessCheck, {allowRowRemoval: true});
}
// Filter columns, omitting any to which the user has no access, regardless of rows.
this._filterColumns(
data[3],
(colId) => this.getReadPermission(permInfo.getColumnAccess(tableId, colId)) !== 'deny');
}
public async getUserOverride(docSession: OptDocSession): Promise<UserOverride|undefined> {
await this.getUser(docSession);
return this._getUserAttributes(docSession).override;
}
public getReadPermission(ps: PermissionSetWithContext) {
return ps.perms.read;
}
public assertCanRead(ps: PermissionSetWithContext) {
accessChecks.fatal.read.get(ps);
}
/**
* Broadcast document changes to all clients, with appropriate filtering.
*/
public async sendDocUpdateForBundle(actionGroup: ActionGroup, docUsage: DocUsageSummary) {
if (!this._activeBundle) { throw new Error('no active bundle'); }
const { docActions, docSession } = this._activeBundle;
const client = docSession && docSession.client || null;
const message: DocUpdateMessage = { actionGroup, docActions, docUsage };
await this._docClients.broadcastDocMessage(client, 'docUserAction',
message,
(_docSession) => this._filterDocUpdate(_docSession, message));
}
/**
* Called when uploads occur. We record the fact that the specified attachment
* ids originated in uploads by the current user, for a certain length of time.
* During that time, attempts by the user to use these attachment ids in an
* attachment column will be accepted. The user is identified by SessionID,
* which is a user id for logged in users, and a session-unique id for
* anonymous users accessing Grist from a browser.
*
* A remaining weakness of this protection could be if attachment ids were
* reused, and reused quickly. Attachments can be deleted after
* REMOVE_UNUSED_ATTACHMENTS_DELAY and on document shutdown. We keep
* UPLOADED_ATTACHMENT_OWNERSHIP_PERIOD less than REMOVE_UNUSED_ATTACHMENTS_DELAY,
* and wipe our records on document shutdown.
*/
public async noteUploads(docSession: OptDocSession, attIds: number[]) {
const user = await this.getUser(docSession);
const id = user.SessionID;
if (!id) {
log.rawError('noteUploads needs a SessionID', {
docId: this._docId,
attIds,
userId: user.UserID,
});
return;
}
for (const attId of attIds) {
this._attachmentUploads.set(attId, id);
}
}
// Remove cached access information for a given session.
public flushAccess(docSession: OptDocSession) {
this._ruler.flushAccess(docSession);
this._userAttributesMap.delete(docSession);
this._prevUserAttributesMap?.delete(docSession);
}
// Get a set of example users for playing with access control.
// We use the example.com domain, which is reserved for uses like this.
public getExampleViewAsUsers(): UserAccessData[] {
return [
{id: 0, email: 'owner@example.com', name: 'Owner', access: 'owners'},
{id: 0, email: 'editor1@example.com', name: 'Editor 1', access: 'editors'},
{id: 0, email: 'editor2@example.com', name: 'Editor 2', access: 'editors'},
{id: 0, email: 'viewer@example.com', name: 'Viewer', access: 'viewers'},
{id: 0, email: 'unknown@example.com', name: 'Unknown User', access: null},
];
}
// Compile a list of users mentioned in user attribute tables keyed by email.
// If there is a Name column or an Access column, in the table, we use them.
public async collectViewAsUsersFromUserAttributeTables(): Promise<Array<Partial<UserAccessData>>> {
const result: Array<Partial<UserAccessData>> = [];
for (const clause of this._ruler.ruleCollection.getUserAttributeRules().values()) {
if (clause.charId !== 'Email') { continue; }
try {
const users = await this._fetchQueryFromDB({
tableId: clause.tableId,
filters: {},
});
const user = new RecordView(users, undefined);
const count = users[2].length;
for (let i = 0; i < count; i++) {
user.index = i;
const email = user.get(clause.lookupColId);
const name = user.get('Name') || String(email).split('@')[0];
const access = user.has('Access') ? String(user.get('Access')) : 'editors';
result.push({
email: email ? String(email) : undefined,
name: name ? String(name) : undefined,
access: isValidRole(access) ? access : null, // 'null' -> null a bit circuitously
});
}
} catch (e) {
log.warn(`User attribute ${clause.name} failed`, e);
}
}
return result;
}
/**
* Get the role the session user has for this document. User may be overridden,
* in which case the role of the override is returned.
* The forkingAsOwner flag of docSession should not be respected for non-owners,
* so that the pseudo-ownership it offers is restricted to granular access within a
* document (as opposed to document-level operations).
*/
public async getNominalAccess(docSession: OptDocSession): Promise<Role|null> {
const linkParameters = docSession.authorizer?.getLinkParameters() || {};
const baseAccess = getDocSessionAccess(docSession);
if ((linkParameters.aclAsUserId || linkParameters.aclAsUser) && baseAccess === 'owners') {
const info = await this.getUser(docSession);
return info.Access;
}
return baseAccess;
}
public async createSnapshotWithCells(docActions?: DocAction[]) {
if (!docActions) {
if (!this._activeBundle) { throw new Error('no active bundle'); }
if (this._activeBundle.applied) {
throw new Error("Can't calculate last state for cell metadata");
}
docActions = this._activeBundle.docActions;
}
const rows = new Map(getRelatedRows(docActions));
const cellData = new CellData(this._docData);
for(const action of docActions) {
for(const cell of cellData.convertToCells(action)) {
if (!rows.has(cell.tableId)) { rows.set(cell.tableId, new Set()); }
rows.get(cell.tableId)?.add(cell.rowId);
}
}
// Don't need to sync _grist_Cells table, since we already have it.
rows.delete('_grist_Cells');
// Populate a minimal in-memory version of the database with these rows.
const docData = new DocData(
async (tableId) => {
return {
tableData: await this._fetchQueryFromDB(
{tableId, filters: {id: [...rows.get(tableId)!]}})
};
}, {
_grist_Cells: this._docData.getMetaTable('_grist_Cells')!.getTableDataAction(),
// We need some basic table information to translate numeric ids to string ids (refs to ids).
_grist_Tables: this._docData.getMetaTable('_grist_Tables')!.getTableDataAction(),
_grist_Tables_column: this._docData.getMetaTable('_grist_Tables_column')!.getTableDataAction()
},
);
// Load pre-existing rows touched by the bundle.
await Promise.all([...rows.keys()].map(tableId => docData.syncTable(tableId)));
return docData;
}
// Return true if attachment info must be sent on a need-to-know basis.
public async needAttachmentControl(docSession: OptDocSession) {
return !await this.canScanData(docSession);
}
/**
* An optimization to catch obvious access problems for simple data
* actions (such as UpdateRecord, BulkAddRecord, etc) early. Checks
* actions one by one (nesting into ApplyUndoActions and
* ApplyDocActions as needed) until meeting one that isn't a simple
* data action. Checks are crude, and limited to the table access
* level. Returns true if all actions were checked, false if
* not. Returning true does not imply the actions in the bundle are
* permissible; returning false does not imply they should be
* denied. Throwing an error DOES imply that an action was
* encountered that should be denied.
*/
private async _checkSimpleDataActions(docSession: OptDocSession, actions: UserAction[]): Promise<boolean> {
for (const action of actions) {
if (!await this._checkSimpleDataAction(docSession, action)) {
return false;
}
}
return true;
}
/**
* Throws an error for simple data actions that the user cannot perform.
* Checking is only at the table level. Returns true if the action clearly
* does not change the document schema or metadata, otherwise false if it might.
*/
private async _checkSimpleDataAction(docSession: OptDocSession, a: UserAction|DocAction): Promise<boolean> {
const name = a[0] as string;
if (name === 'ApplyUndoActions') {
return this._checkSimpleDataActions(docSession, a[1] as UserAction[]);
} else if (name === 'ApplyDocActions') {
return this._checkSimpleDataActions(docSession, a[1] as UserAction[]);
} else if (isDataAction(a)) {
const tableId = getTableId(a);
if (tableId.startsWith('_grist_')) {
return false;
}
const tableAccess = await this.getTableAccess(docSession, tableId);
const accessCheck = await this._getAccessForActionType(docSession, a, 'fatal');
accessCheck.get(tableAccess); // will throw if access denied.
return true;
} else {
// Any other action might change schema, so continuing could lead
// to false detections of failures. For example, renaming a column
// and then updating cells within it should be allowed.
return false;
}
}
private async _checkForSpecialOrSurprisingActions(docSession: OptDocSession,
actions: UserAction[]) {
await applyToActionsRecursively(actions, async (a) => {
const name = String(a[0]);
if (SPECIAL_ACTIONS.has(name)) {
if (await this.hasNuancedAccess(docSession)) {
throw new ErrorWithCode('ACL_DENY', `Blocked by access rules: '${name}' actions need uncomplicated access`);
}
} else if (SURPRISING_ACTIONS.has(name)) {
if (!await this.hasFullAccess(docSession)) {
throw new ErrorWithCode('ACL_DENY', `Blocked by access rules: '${name}' actions need full access`);
}
} else if (OK_ACTIONS.has(name)) {
// fine, anyone can do these at any time, continue.
} else if (OTHER_RECOGNIZED_ACTIONS.has(name)) {
// these are known actions that have not been specifically classified.
} else {
// we've hit something unexpected - perhaps a UserAction has been added
// without considering access control.
throw new ErrorWithCode('ACL_DENY', `Blocked by access rules: '${name}' actions are not controlled`);
}
});
}
// AddOrUpdateRecord requires broad read access to a table.
// But tables can be renamed, and access can be granted and removed
// within a bundle.
//
// For now, we forbid the combination of AddOrUpdateRecord and
// with actions other than other AddOrUpdateRecords, or simple data
// changes.
//
// Access rules and user attributes might change during the bundle.
// We deny based on access rights at the beginning of the bundle,
// as for _checkPossiblePythonFormulaModification. This is on the
// theory that someone who can change access rights can do anything.
//
// There might be uses for applying AddOrUpdateRecord in a nuanced
// way within the scope of what a user can read, but there's no easy
// way to do that within the data engine as currently
// formulated. Could perhaps be done for on-demand tables though.
private async _checkAddOrUpdateAccess(docSession: OptDocSession, actions: UserAction[]) {
(core) Add BulkAddOrUpdateRecord action for efficiency Summary: This diff adds a new `BulkAddOrUpdateRecord` user action which is what is sounds like: - A bulk version of the existing `AddOrUpdateRecord` action. - Much more efficient for operating on many records than applying many individual actions. - Column values are specified as maps from `colId` to arrays of values as usual. - Produces bulk versions of `AddRecord` and `UpdateRecord` actions instead of many individual actions. Examples of users wanting to use something like `AddOrUpdateRecord` with large numbers of records: - https://grist.slack.com/archives/C0234CPPXPA/p1651789710290879 - https://grist.slack.com/archives/C0234CPPXPA/p1660743493480119 - https://grist.slack.com/archives/C0234CPPXPA/p1660333148491559 - https://grist.slack.com/archives/C0234CPPXPA/p1663069291726159 I tested what made many `AddOrUpdateRecord` actions slow in the first place. It was almost entirely due to producing many individual `AddRecord` user actions. About half of that time was for processing the resulting `AddRecord` doc actions. Lookups and updates were not a problem. With these changes, the slowness is gone. The Python user action implementation is more complex but there are no surprises. The JS API now groups `records` based on the keys of `require` and `fields` so that `BulkAddOrUpdateRecord` can be applied to each group. Test Plan: Update and extend Python and DocApi tests. Reviewers: jarek, paulfitz Reviewed By: jarek, paulfitz Subscribers: jarek Differential Revision: https://phab.getgrist.com/D3642
2022-09-28 13:13:07 +00:00
if (!scanActionsRecursively(actions, isAddOrUpdateRecordAction)) {
// Don't need to apply this particular check.
return;
}
await this._assertOnlyBundledWithSimpleDataActions(ADD_OR_UPDATE_RECORD_ACTIONS, actions);
// Check for read access, and that we're not touching metadata.
await applyToActionsRecursively(actions, async (a) => {
(core) Add BulkAddOrUpdateRecord action for efficiency Summary: This diff adds a new `BulkAddOrUpdateRecord` user action which is what is sounds like: - A bulk version of the existing `AddOrUpdateRecord` action. - Much more efficient for operating on many records than applying many individual actions. - Column values are specified as maps from `colId` to arrays of values as usual. - Produces bulk versions of `AddRecord` and `UpdateRecord` actions instead of many individual actions. Examples of users wanting to use something like `AddOrUpdateRecord` with large numbers of records: - https://grist.slack.com/archives/C0234CPPXPA/p1651789710290879 - https://grist.slack.com/archives/C0234CPPXPA/p1660743493480119 - https://grist.slack.com/archives/C0234CPPXPA/p1660333148491559 - https://grist.slack.com/archives/C0234CPPXPA/p1663069291726159 I tested what made many `AddOrUpdateRecord` actions slow in the first place. It was almost entirely due to producing many individual `AddRecord` user actions. About half of that time was for processing the resulting `AddRecord` doc actions. Lookups and updates were not a problem. With these changes, the slowness is gone. The Python user action implementation is more complex but there are no surprises. The JS API now groups `records` based on the keys of `require` and `fields` so that `BulkAddOrUpdateRecord` can be applied to each group. Test Plan: Update and extend Python and DocApi tests. Reviewers: jarek, paulfitz Reviewed By: jarek, paulfitz Subscribers: jarek Differential Revision: https://phab.getgrist.com/D3642
2022-09-28 13:13:07 +00:00
if (!isAddOrUpdateRecordAction(a)) { return; }
const actionName = String(a[0]);
const tableId = validTableIdString(a[1]);
if (tableId.startsWith('_grist_')) {
throw new Error(`${actionName} cannot yet be used on metadata tables`);
}
const tableAccess = await this.getTableAccess(docSession, tableId);
accessChecks.fatal.read.throwIfNotFullyAllowed(tableAccess);
accessChecks.fatal.update.throwIfDenied(tableAccess);
accessChecks.fatal.create.throwIfDenied(tableAccess);
});
}
/**
* Asserts that `actionNames` (if present in `actions`) are only bundled with simple data actions.
*/
private async _assertOnlyBundledWithSimpleDataActions(actionNames: string | string[], actions: UserAction[]) {
const names = Array.isArray(actionNames) ? actionNames : [actionNames];
// Fail if being combined with anything that isn't a simple data action.
await applyToActionsRecursively(actions, async (a) => {
const name = String(a[0]);
if (!names.includes(name) && !(isDataAction(a) && !getTableId(a).startsWith('_grist_'))) {
throw new Error(`Can only combine ${names.join(' and ')} with simple data changes`);
}
});
}
private async _checkIfNeedsEarlySchemaPermission(docSession: OptDocSession, actions: UserAction[]) {
// If changes could include Python formulas, then user must have
// +S before we even consider passing these to the data engine.
// Since we don't track rule or schema changes at this stage, we
// approximate with the user's access rights at beginning of
// bundle.
// We also check for +S in scenarios that are hard to break down
// in a more granular way, for example ConvertFromColumn and
// CopyFromColumn.
if (scanActionsRecursively(actions, (a) => this.needEarlySchemaPermission(a))) {
await this._assertSchemaAccess(docSession);
}
}
/**
* Like `_checkAddOrUpdateAccess`, but for DuplicateTable actions.
*
* Permitted only when a user has full access, or full table read and schema edit
* access for the table being duplicated.
*
* Currently, DuplicateTable cannot be combined with other action types, including
* simple data actions. This may be relaxed in the future, but should only be done
* after careful consideration of its implications.
*/
private async _checkDuplicateTableAccess(docSession: OptDocSession, actions: UserAction[]) {
if (!scanActionsRecursively(actions, ([actionName]) => String(actionName) === 'DuplicateTable')) {
// Don't need to apply this particular check.
return;
}
// Fail if being combined with another action.
await applyToActionsRecursively(actions, async ([actionName]) => {
if (String(actionName) !== 'DuplicateTable') {
throw new Error('DuplicateTable currently cannot be combined with other actions');
}
});
// Check for read and schema edit access, and that we're not duplicating metadata tables.
await applyToActionsRecursively(actions, async (a) => {
const tableId = validTableIdString(a[1]);
if (tableId.startsWith('_grist_')) {
throw new Error('DuplicateTable cannot be used on metadata tables');
}
if (await this.hasFullAccess(docSession)) { return; }
const tableAccess = await this.getTableAccess(docSession, tableId);
accessChecks.fatal.read.throwIfNotFullyAllowed(tableAccess);
accessChecks.fatal.schemaEdit.throwIfDenied(tableAccess);
const includeData = a[3];
if (includeData) {
accessChecks.fatal.create.throwIfDenied(tableAccess);
}
});
}
/**
* Asserts that user has schema access.
*/
private async _assertSchemaAccess(docSession: OptDocSession) {
if (this._hasExceptionalFullAccess(docSession)) { return; }
const permInfo = await this._getAccess(docSession);
accessChecks.fatal.schemaEdit.throwIfDenied(permInfo.getFullAccess());
}
// The AccessCheck for the "read" permission is used enough to merit a shortcut.
// We just need to be careful to retain unfettered access for exceptional sessions.
private _readAccessCheck(docSession: OptDocSession): IAccessCheck {
return this._hasExceptionalFullAccess(docSession) ? dummyAccessCheck : accessChecks.check.read;
}
// Return true for special system sessions or document-creation sessions, where
// unfettered access is appropriate.
private _hasExceptionalFullAccess(docSession: OptDocSession): Boolean {
return docSession.mode === 'system' || docSession.mode === 'nascent';
}
/**
* This filters a message being broadcast to all clients to be appropriate for one
* particular client, if that client may need some material filtered out.
*/
private async _filterDocUpdate(docSession: OptDocSession, message: DocUpdateMessage) {
if (!this._activeBundle) { throw new Error('no active bundle'); }
const role = await this.getNominalAccess(docSession);
const result = {
...message,
docUsage: await this.filterDocUsageSummary(docSession, message.docUsage, {role}),
};
if (!this._ruler.haveRules() && !this._activeBundle.hasDeliberateRuleChange) {
return result;
}
result.actionGroup = await this.filterActionGroup(docSession, message.actionGroup, {role});
result.docActions = await this.filterOutgoingDocActions(docSession, message.docActions);
if (result.docActions.length === 0) { return null; }
return result;
}
private async _updateRules(docActions: DocAction[]) {
// If there is a rule change, redo from scratch for now.
// TODO: this is placeholder code. Should deal with connected clients.
if (docActions.some(docAction => isAclTable(getTableId(docAction)))) {
await this.update();
return;
}
(core) add initial support for special shares Summary: This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules. It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with: * When reading rules, if there are shares, extra rules are added. * If there are shares, all rules are made conditional on a "ShareRef" user property. * "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share. There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do: ``` gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}]) ``` If you look at the home db now there should be something in the `shares` table: ``` $ sqlite3 -table landing.db "select * from shares" +----+------------------------+------------------------+--------------+---------+ | id | key | doc_id | link_id | options | +----+------------------------+------------------------+--------------+---------+ | 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... | +----+------------------------+------------------------+--------------+---------+ ``` If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share. E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc. To actually share some material - useful commands: ``` gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}]) gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}]) ``` For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options. I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard. Test Plan: tests added Reviewers: dsagal, georgegevoian Reviewed By: dsagal, georgegevoian Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
const shares = this._docData.getMetaTable('_grist_Shares');
if (shares.getRowIds().length > 0 &&
docActions.some(action => getTableId(action).startsWith('_grist'))) {
await this.update();
return;
}
if (!shares && !this._ruler.haveRules()) {
return;
}
// If there is a schema change, redo from scratch for now.
if (docActions.some(docAction => isSchemaAction(docAction))) {
await this.update();
}
}
/**
* Strip out any denied columns from an action. Returns null if nothing is left.
* accessCheck may throw if denials are fatal.
*/
private _pruneColumns(a: DocAction, permInfo: IPermissionInfo, tableId: string,
accessCheck: IAccessCheck): DocAction|null {
permInfo = new TransformColumnPermissionInfo(permInfo);
if (a[0] === 'RemoveRecord' || a[0] === 'BulkRemoveRecord') {
return a;
} else if (a[0] === 'AddRecord' || a[0] === 'BulkAddRecord' || a[0] === 'UpdateRecord' ||
a[0] === 'BulkUpdateRecord' || a[0] === 'ReplaceTableData' || a[0] === 'TableData') {
const na = cloneDeep(a);
this._filterColumns(na[3], (colId) => accessCheck.get(permInfo.getColumnAccess(tableId, colId)) !== 'deny');
if (Object.keys(na[3]).length === 0) { return null; }
return na;
} else if (a[0] === 'AddColumn' || a[0] === 'RemoveColumn' || a[0] === 'RenameColumn' ||
a[0] === 'ModifyColumn') {
const colId: string = a[2];
if (accessCheck.get(permInfo.getColumnAccess(tableId, colId)) === 'deny') { return null; }
} else {
// Remaining cases of AddTable, RemoveTable, RenameTable should have
// been handled at the table level.
}
return a;
}
/**
* Strip out any denied rows from an action. The action may be rewritten if rows
* become allowed or denied during the action. An action to add newly-allowed
* rows may be included, or an action to remove newly-forbidden rows. The result
* is a list rather than a single action. It may be the empty list.
*/
private async _pruneRows(cursor: ActionCursor): Promise<DocAction[]> {
const {action} = cursor;
// This only deals with Record-related actions.
if (!isDataAction(action)) { return [action]; }
// Get before/after state for this action. Broadcasts to other users can make use of the
// same state, so we share it (and only compute it if needed).
const {rowsBefore, rowsAfter} = await this._getRowsBeforeAndAfter(cursor);
// Figure out which rows were forbidden to this session before this action vs
// after this action. We need to know both so that we can infer the state of the
// client and send the correct change.
const orderedIds = getRowIdsFromDocAction(action);
const ids = new Set(orderedIds);
const forbiddenBefores = new Set(await this._getForbiddenRows(cursor, rowsBefore, ids));
const forbiddenAfters = new Set(await this._getForbiddenRows(cursor, rowsAfter, ids));
/**
* For rows forbidden before and after: just remove them.
* For rows allowed before and after: just leave them unchanged.
* For rows that were allowed before and are now forbidden:
* - strip them from the current action.
* - add a BulkRemoveRecord for them.
* For rows that were forbidden before and are now allowed:
* - remove them from the current action.
* - add a BulkAddRecord for them.
*/
const removals = new Set<number>(); // rows to remove from current action.
const forceAdds = new Set<number>(); // rows to add, that were previously stripped.
const forceRemoves = new Set<number>(); // rows to remove, that have become forbidden.
for (const id of ids) {
const forbiddenBefore = forbiddenBefores.has(id);
const forbiddenAfter = forbiddenAfters.has(id);
if (!forbiddenBefore && !forbiddenAfter) { continue; }
if (forbiddenBefore && forbiddenAfter) {
removals.add(id);
continue;
}
// If we reach here, then access right to the row changed and we have fancy footwork to do.
if (forbiddenBefore) {
// The row was forbidden and now is allowed. That's trivial if the row was just added.
if (action[0] === 'AddRecord' || action[0] === 'BulkAddRecord' ||
action[0] === 'ReplaceTableData' || action[0] === 'TableData') {
continue;
}
// Otherwise, strip the row from the current action.
removals.add(id);
if (action[0] === 'UpdateRecord' || action[0] === 'BulkUpdateRecord') {
// For updates, we need to send the entire row as an add, since the client
// doesn't know anything about it yet.
forceAdds.add(id);
} else {
// Remaining cases are [Bulk]RemoveRecord.
}
} else {
// The row was allowed and now is forbidden.
// If the action is a removal, that is just right.
if (action[0] === 'RemoveRecord' || action[0] === 'BulkRemoveRecord') { continue; }
// Otherwise, strip the row from the current action.
removals.add(id);
if (action[0] === 'UpdateRecord' || action[0] === 'BulkUpdateRecord') {
// For updates, we need to remove the entire row.
forceRemoves.add(id);
} else {
// Remaining cases are add-like actions.
}
}
}
// Execute our cunning plans for DocAction revisions.
const revisedDocActions = [
this._makeAdditions(rowsAfter, forceAdds),
this._removeRows(action, removals),
this._makeRemovals(rowsAfter, forceRemoves),
].filter(isNonNullish);
// Check whether there are column rules for this table, and if so whether they are row
// dependent. If so, we may need to update visibility of cells not mentioned in the
// original DocAction.
// No censorship is done here, all we do at this point is pull in any extra cells that need
// to be updated for the current client. Censorship for these cells, and any cells already
// present in the DocAction, is done by _filterRowsAndCells.
const ruler = await this._getRuler(cursor);
const tableId = getTableId(action);
const ruleSets = ruler.ruleCollection.getAllColumnRuleSets(tableId);
const colIds = new Set(([] as string[]).concat(
...ruleSets.map(ruleSet => ruleSet.colIds === '*' ? [] : ruleSet.colIds)
));
const access = await ruler.getAccess(cursor.docSession);
// Check columns in a consistent order, for determinism (easier testing).
// TODO: could pool some work between columns by doing them together rather than one by one.
for (const colId of [...colIds].sort()) {
// If the column is already in the DocAction, we can skip checking if we need to add it.
if (!action[3] || (colId in action[3])) { continue; }
// If the column is not row dependent, we have nothing to do.
if (access.getColumnAccess(tableId, colId).perms.read !== 'mixed') { continue; }
// Check column accessibility before and after.
const _forbiddenBefores = new Set(await this._getForbiddenRows(cursor, rowsBefore, ids, colId));
const _forbiddenAfters = new Set(await this._getForbiddenRows(cursor, rowsAfter, ids, colId));
// For any column that is in a visible row and for which accessibility has changed,
// pull it into the doc actions. We don't censor cells yet, that happens later
// (if that's what needs doing).
const changedIds = orderedIds.filter(id => !forceRemoves.has(id) && !removals.has(id) &&
(_forbiddenBefores.has(id) !== _forbiddenAfters.has(id)));
if (changedIds.length > 0) {
revisedDocActions.push(this._makeColumnUpdate(rowsAfter, colId, new Set(changedIds)));
}
}
// Return the results, also applying any cell-level access control.
const readAccessCheck = this._readAccessCheck(cursor.docSession);
const filteredDocActions: DocAction[] = [];
for (const a of revisedDocActions) {
const {filteredAction} =
await this._filterRowsAndCells({...cursor, action: a}, rowsAfter, rowsAfter, readAccessCheck,
{allowRowRemoval: false, copyOnModify: true});
if (filteredAction) { filteredDocActions.push(filteredAction); }
}
return filteredDocActions;
}
/**
* Like _pruneRows, but fails immediately if access to any row is forbidden.
* The accessCheck supplied should throw an error on denial.
*/
private async _checkRows(cursor: ActionCursor, accessCheck: IAccessCheck): Promise<void> {
const {action} = cursor;
// This check applies to data changes only.
if (!isDataAction(action)) { return; }
const {rowsBefore, rowsAfter} = await this._getRowsForRecAndNewRec(cursor);
// If any change is needed, this call will fail immediately because we are using
// access checks that throw.
await this._filterRowsAndCells(cursor, rowsBefore, rowsAfter, accessCheck,
{allowRowRemoval: false});
}
private async _getRowsBeforeAndAfter(cursor: ActionCursor) {
const {rowsBefore, rowsAfter} = await this._getStep(cursor);
if (!rowsBefore || !rowsAfter) { throw new Error('Logic error: no rows available'); }
return {rowsBefore, rowsAfter};
}
private async _getRowsForRecAndNewRec(cursor: ActionCursor) {
const steps = await this._getSteps();
if (cursor.actionIdx === null) { throw new Error('No step available'); }
const {rowsBefore, rowsLast} = steps[cursor.actionIdx];
if (!rowsBefore) { throw new Error('Logic error: no previous rows available'); }
if (rowsLast) {
return {rowsBefore, rowsAfter: rowsLast};
}
// When determining whether to apply an action, we choose to make newRec refer to the
// state at the end of the entire bundle. So we look for the last pair of row snapshots
// for the same table.
// TODO: there's a problem that this could alias rows if row ids were reused within the
// same bundle. It is kind of a slippery idea. Likewise, column renames are slippery.
// We could solve a lot of slipperiness by having newRec not transition across schema
// changes, but we don't really have the option because formula updates happen late.
let tableId = getTableId(rowsBefore);
let last = cursor.actionIdx;
for (let i = last + 1; i < steps.length; i++) {
const act = steps[i].action;
if (getTableId(act) !== tableId) { continue; }
if (act[0] === 'RenameTable') {
tableId = act[2];
continue;
}
last = i;
}
const rowsAfter = steps[cursor.actionIdx].rowsLast = steps[last].rowsAfter;
if (!rowsAfter) { throw new Error('Logic error: no next rows available'); }
return {rowsBefore, rowsAfter};
}
/**
* Scrub any rows and cells to which access is not granted from an
* action. Returns filteredAction, which is the provided action, a
* modified copy of the provided action, or null. It is null if the
* action was entirely eliminated (and was not a bulk action). It is
* a modified copy if any scrubbing was needed and copyOnModify is
* set, otherwise the original is modified in place.
*
* Also returns censoredRows, a set of indexes of rows that have a
* censored value in them.
*
* If allowRowRemoval is false, then rows will not be removed, and if the user
* does not have access to a row and the action itself is not a remove action, then
* an error will be thrown. This flag setting is used when filtering outgoing
* actions, where actions need rewriting elsewhere to reflect access changes to
* rows for each individual client.
*/
private async _filterRowsAndCells(cursor: ActionCursor, rowsBefore: TableDataAction, rowsAfter: TableDataAction,
accessCheck: IAccessCheck,
options: {
allowRowRemoval?: boolean,
copyOnModify?: boolean,
}): Promise<{
filteredAction: DocAction | null,
censoredRows: Set<number>
}> {
const censoredRows = new Set<number>();
const ruler = await this._getRuler(cursor);
const {docSession, action} = cursor;
if (action && isSchemaAction(action)) {
return {filteredAction: action, censoredRows};
}
let filteredAction: DocAction | null = action;
// For user convenience, for creations and deletions we equate rec and newRec.
// This makes writing rules that control multiple permissions easier to write in
// practice.
let rowsRec = rowsBefore;
let rowsNewRec = rowsAfter;
if (isAddRecordAction(action)) {
rowsRec = rowsAfter;
} else if (isRemoveRecordAction(action)) {
rowsNewRec = rowsBefore;
}
const rec = new RecordView(rowsRec, undefined);
const newRec = new RecordView(rowsNewRec, undefined);
const input: PredicateFormulaInput = {...await this.inputs(docSession), rec, newRec};
const [, tableId, , colValues] = action;
let filteredColValues: ColValues | BulkColValues | undefined | null = null;
const rowIds = getRowIdsFromDocAction(action);
const toRemove: number[] = [];
// Call this to make sure we are modifying a copy, not the original, if copyOnModify is set.
const copyOnNeed = () => {
if (filteredColValues === null) {
filteredAction = options?.copyOnModify ? cloneDeep(action) : action;
filteredColValues = filteredAction[3];
}
return filteredColValues;
};
let censorAt: (colId: string, idx: number) => void;
if (colValues === undefined) {
censorAt = () => 1;
} else if (Array.isArray(action[2])) {
censorAt = (colId, idx) => (copyOnNeed() as BulkColValues)[colId][idx] = [GristObjCode.Censored];
} else {
censorAt = (colId) => (copyOnNeed() as ColValues)[colId] = [GristObjCode.Censored];
}
// These map an index of a row in the action to its index in rowsBefore and in rowsAfter.
let getRecIndex: (idx: number) => number|undefined = (idx) => idx;
let getNewRecIndex: (idx: number) => number|undefined = (idx) => idx;
if (action !== rowsRec) {
const recIndexes = new Map(rowsRec[2].map((rowId, idx) => [rowId, idx]));
getRecIndex = (idx) => recIndexes.get(rowIds[idx]);
}
if (action !== rowsNewRec) {
const newRecIndexes = new Map(rowsNewRec[2].map((rowId, idx) => [rowId, idx]));
getNewRecIndex = (idx) => newRecIndexes.get(rowIds[idx]);
}
for (let idx = 0; idx < rowIds.length; idx++) {
rec.index = getRecIndex(idx);
newRec.index = getNewRecIndex(idx);
const rowPermInfo = new PermissionInfo(ruler.ruleCollection, input);
// getTableAccess() evaluates all column rules for THIS record. So it's really rowAccess.
const rowAccess = rowPermInfo.getTableAccess(tableId);
const access = accessCheck.get(rowAccess);
if (access === 'deny') {
toRemove.push(idx);
} else if (access !== 'allow' && colValues) {
// Go over column rules.
for (const colId of Object.keys(colValues)) {
const colAccess = rowPermInfo.getColumnAccess(tableId, colId);
if (accessCheck.get(colAccess) === 'deny') {
censorAt(colId, idx);
censoredRows.add(idx);
}
}
}
}
if (toRemove.length > 0) {
if (options.allowRowRemoval) {
copyOnNeed();
if (Array.isArray(filteredAction[2])) {
this._removeRowsAt(toRemove, filteredAction[2], filteredAction[3]);
} else {
filteredAction = null;
}
} else {
// Artificially introduced removals are ok, otherwise this is suspect.
if (filteredAction[0] !== 'RemoveRecord' && filteredAction[0] !== 'BulkRemoveRecord') {
throw new Error('Unexpected row removal');
}
}
}
return {filteredAction, censoredRows};
}
// Compute which of the row ids supplied are for rows forbidden for this session.
// If colId is supplied, check instead whether that specific column is forbidden.
private async _getForbiddenRows(cursor: ActionCursor, data: TableDataAction, ids: Set<number>,
colId?: string): Promise<number[]> {
const ruler = await this._getRuler(cursor);
const rec = new RecordView(data, undefined);
const input: PredicateFormulaInput = {...await this.inputs(cursor.docSession), rec};
const [, tableId, rowIds] = data;
const toRemove: number[] = [];
for (let idx = 0; idx < rowIds.length; idx++) {
rec.index = idx;
if (!ids.has(rowIds[idx])) { continue; }
const rowPermInfo = new PermissionInfo(ruler.ruleCollection, input);
// getTableAccess() evaluates all column rules for THIS record. So it's really rowAccess.
const rowAccess = rowPermInfo.getTableAccess(tableId);
if (!colId) {
if (this.getReadPermission(rowAccess) === 'deny') {
toRemove.push(rowIds[idx]);
}
} else {
const colAccess = rowPermInfo.getColumnAccess(tableId, colId);
if (this.getReadPermission(colAccess) === 'deny') {
toRemove.push(rowIds[idx]);
}
}
}
return toRemove;
}
/**
* Removes the toRemove rows (indexes, not row ids) from the rowIds list and from
* the colValues structure.
*
* toRemove must be sorted, lowest to highest.
*/
private _removeRowsAt(toRemove: number[], rowIds: number[], colValues: BulkColValues|ColValues|undefined) {
if (toRemove.length > 0) {
pruneArray(rowIds, toRemove);
if (colValues) {
for (const values of Object.values(colValues)) {
pruneArray(values, toRemove);
}
}
}
}
/**
* Remove columns from a ColumnValues parameter of certain DocActions, using a predicate for
* which columns to keep.
* Will retain manualSort columns regardless of wildcards.
*/
private _filterColumns(data: BulkColValues|ColValues, shouldInclude: (colId: string) => boolean) {
for (const colId of Object.keys(data)) {
if (colId !== 'manualSort' && !shouldInclude(colId)) {
delete data[colId];
}
}
}
/**
* Get PermissionInfo for the user represented by the given docSession. The returned object
* allows evaluating access level as far as possible without considering specific records.
*
* The result is cached in a WeakMap, and PermissionInfo does its own caching, so multiple calls
* to this._getAccess(docSession).someMethod() will reuse already-evaluated results.
*/
private async _getAccess(docSession: OptDocSession): Promise<PermissionInfo> {
// TODO The intent of caching is to avoid duplicating rule evaluations while processing a
// single request. Caching based on docSession is riskier since those persist across requests.
return this._ruler.getAccess(docSession);
}
private _getUserAttributes(docSession: OptDocSession): UserAttributes {
// TODO Same caching intent and caveat as for _getAccess
return getSetMapValue(this._userAttributesMap as Map<OptDocSession, UserAttributes>, docSession,
() => new UserAttributes());
}
/**
* Check whether user attributes have changed. If so, prompt client
* to reload the document, since we aren't sophisticated enough to
* figure out the changes to send.
*/
private async _checkUserAttributes(docSession: OptDocSession) {
if (!this._prevUserAttributesMap) { return; }
const userAttrBefore = this._prevUserAttributesMap.get(docSession);
if (!userAttrBefore) { return; }
await this._getAccess(docSession); // Makes sure user attrs have actually been computed.
const userAttrAfter = this._getUserAttributes(docSession);
for (const [tableId, rec] of Object.entries(userAttrAfter.rows)) {
const prev = userAttrBefore.rows[tableId];
if (!prev || JSON.stringify(prev.toJSON()) !== JSON.stringify(rec.toJSON())) {
throw new ErrorWithCode('NEED_RELOAD', 'document needs reload, user attributes changed');
}
}
(core) support adding user characteristic tables for granular ACLs Summary: This is a prototype for expanding the conditions that can be used in granular ACLs. When processing ACLs, the following variables (called "characteristics") are now available in conditions: * UserID * Email * Name * Access (owners, editors, viewers) The set of variables can be expanded by adding a "characteristic" clause. This is a clause which specifies: * A tableId * The name of an existing characteristic * A colId The effect of the clause is to expand the available characteristics with all the columns in the table, with values taken from the record where there is a match between the specified characteristic and the specified column. Existing clauses are generalized somewhat to demonstrate and test the use these variables. That isn't the main point of this diff though, and I propose to leave generalizing+systematizing those clauses for a future diff. Issues I'm not dealing with here: * How clauses combine. (The scope on GranularAccessRowClause is a hack to save me worrying about that yet). * The full set of matching methods we'll allow. * Refreshing row access in clients when the tables mentioned in characteristic tables change. * Full CRUD permission control. * Default rules (part of combination). * Reporting errors in access rules. That said, with this diff it is possible to e.g. assign a City to editors by their email address or name, and have only rows for those Cities be visible in their client. Ability to modify those rows, and remain updates about them, remains under incomplete control. Test Plan: added tests Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2642
2020-10-19 14:25:21 +00:00
}
/**
* Get the "View As" user specified in link parameters.
* If aclAsUserId is set, we get the user with the specified id.
* If aclAsUser is set, we get the user with the specified email,
* from the database if possible, otherwise from user attribute
* tables or examples.
*/
private async _getViewAsUser(linkParameters: Record<string, string>): Promise<UserOverride> {
// Look up user information in database, if available
const dbUser = linkParameters.aclAsUserId ?
(await this._homeDbManager?.getUser(integerParam(linkParameters.aclAsUserId, 'aclAsUserId'))) :
(await this._homeDbManager?.getExistingUserByLogin(linkParameters.aclAsUser));
// If this is one of example users we will pretend that it doesn't exist, otherwise we would
// end up using permissions of the real user.
const isExampleUser = this.getExampleViewAsUsers().some(e => e.email === dbUser?.loginEmail);
const userExists = dbUser && !isExampleUser;
if (!userExists && linkParameters.aclAsUser) {
// Look further for the user, in user attribute tables or examples.
const otherUsers = (await this.collectViewAsUsersFromUserAttributeTables())
.concat(this.getExampleViewAsUsers());
const email = normalizeEmail(linkParameters.aclAsUser);
const dummyUser = otherUsers.find(user => normalizeEmail(user?.email || '') === email);
if (dummyUser) {
return {
access: dummyUser.access || null,
user: {
id: -1,
email: dummyUser.email!,
name: dummyUser.name || dummyUser.email!,
}
};
}
}
const docAuth = userExists ? await this._homeDbManager?.getDocAuthCached({
urlId: this._docId,
userId: dbUser.id
}) : null;
const access = docAuth?.access || null;
const user = userExists ? this._homeDbManager?.makeFullUser(dbUser) : null;
return { access, user: user || null };
}
/**
* Remove a set of rows from a DocAction. If the DocAction ends up empty, null is returned.
* If the DocAction needs modification, it is copied first - the original is never
* changed.
*/
private _removeRows(a: DocAction, rowIds: Set<number>): DocAction|null {
// If there are no rows, there's nothing to do.
if (isSchemaAction(a)) { return a; }
if (a[0] === 'AddRecord' || a[0] === 'UpdateRecord' || a[0] === 'RemoveRecord') {
return rowIds.has(a[2]) ? null : a;
}
const na = cloneDeep(a);
const [, , oldIds, bulkColValues] = na;
const mask = oldIds.map((id, idx) => rowIds.has(id) ? idx : false).filter(v => v !== false) as number[];
this._removeRowsAt(mask, oldIds, bulkColValues);
if (oldIds.length === 0) { return null; }
return na;
}
/**
* Make a BulkAddRecord for a set of rows.
*/
private _makeAdditions(data: TableDataAction, rowIds: Set<number>): BulkAddRecord|null {
if (rowIds.size === 0) { return null; }
// TODO: optimize implementation, this does an unnecessary clone.
const notAdded = data[2].filter(id => !rowIds.has(id));
const partialData = this._removeRows(data, new Set(notAdded)) as TableDataAction|null;
if (partialData === null) { return partialData; }
return ['BulkAddRecord', partialData[1], partialData[2], partialData[3]];
}
/**
* Make a BulkRemoveRecord for a set of rows.
*/
private _makeRemovals(data: TableDataAction, rowIds: Set<number>): BulkRemoveRecord|null {
if (rowIds.size === 0) { return null; }
return ['BulkRemoveRecord', getTableId(data), [...rowIds]];
}
/**
* Make a BulkUpdateRecord for a particular column across a set of rows.
*/
private _makeColumnUpdate(data: TableDataAction, colId: string, rowIds: Set<number>): BulkUpdateRecord {
const dataRowIds = data[2];
const selectedRowIds = dataRowIds.filter(r => rowIds.has(r));
const colData = data[3][colId].filter((value, idx) => rowIds.has(dataRowIds[idx]));
return ['BulkUpdateRecord', getTableId(data), selectedRowIds, {[colId]: colData}];
}
private async _getSteps(): Promise<Array<ActionStep>> {
if (!this._steps) {
this._steps = this._getUncachedSteps().catch(e => {
log.error('step computation failed:', e);
throw e;
});
}
return this._steps;
}
private async _getMetaSteps(): Promise<Array<MetaStep>> {
if (!this._metaSteps) {
this._metaSteps = this._getUncachedMetaSteps().catch(e => {
log.error('meta step computation failed:', e);
throw e;
});
}
return this._metaSteps;
}
/**
* Prepare to compute intermediate states of rows, as
* this._steps. The computation should happen only if
* needed, which depends on the rules and actions. The computation
* uses the state of the database, and so depends on whether the
* docActions have already been applied to the database or not, as
* determined by the this._applied flag, which should never be
* changed during any possible use of this._steps.
*/
private async _getUncachedSteps(): Promise<Array<ActionStep>> {
if (!this._activeBundle) { throw new Error('no active bundle'); }
const {docActions, undo, applied} = this._activeBundle;
// For row access work, we'll need to know the state of affected rows before and
// after the actions.
// First figure out what rows in which tables are touched during the actions.
const rows = new Map(getRelatedRows(applied ? [...undo].reverse() : docActions));
// Populate a minimal in-memory version of the database with these rows.
const docData = new DocData(
async (tableId) => {
return {
tableData: await this._fetchQueryFromDB({tableId, filters: {id: [...rows.get(tableId)!]}})
};
},
null,
);
// Load pre-existing rows touched by the bundle.
await Promise.all([...rows.keys()].map(tableId => docData.syncTable(tableId)));
if (applied) {
// Apply the undo actions, since the docActions have already been applied to the db.
for (const docAction of [...undo].reverse()) { docData.receiveAction(docAction); }
}
// Now step forward, storing the before and after state for the table
// involved in each action. We'll use this to compute row access changes.
// For simple changes, the rows will be just the minimal set needed.
// This could definitely be optimized. E.g. for pure table updates, these
// states could be extracted while applying undo actions, with no need for
// a forward pass. And for a series of updates to the same table, there'll
// be duplicated before/after states that could be optimized.
const steps = new Array<ActionStep>();
for (const docAction of docActions) {
const tableId = getTableId(docAction);
const tableData = docData.getTable(tableId);
const rowsBefore = cloneDeep(tableData?.getTableDataAction() || ['TableData', '', [], {}] as TableDataAction);
docData.receiveAction(docAction);
// If table is deleted, state afterwards doesn't matter.
const rowsAfter = docData.getTable(tableId) ?
cloneDeep(tableData?.getTableDataAction() || ['TableData', '', [], {}] as TableDataAction) :
rowsBefore;
const step: ActionStep = {action: docAction, rowsBefore, rowsAfter};
steps.push(step);
}
return steps;
}
/**
* Prepare to compute intermediate metadata and rules, as this._metaSteps.
*/
private async _getUncachedMetaSteps(): Promise<Array<MetaStep>> {
if (!this._activeBundle) { throw new Error('no active bundle'); }
const {docActions, undo, applied} = this._activeBundle;
const needMeta = docActions.some(a => isSchemaAction(a) || getTableId(a).startsWith('_grist_'));
if (!needMeta) {
// Sometimes, the intermediate states are trivial.
// TODO: look into whether it would be worth caching attachment columns.
const attachmentColumns = getAttachmentColumns(this._docData);
return docActions.map(action => ({action, attachmentColumns}));
}
const metaDocData = new DocData(
async (tableId) => {
const result = this._docData.getTable(tableId)?.getTableDataAction();
if (!result) { throw new Error('surprising load'); }
return {tableData: result};
},
null,
);
// Read the structural tables.
await Promise.all([...STRUCTURAL_TABLES].map(tableId => metaDocData.syncTable(tableId)));
if (applied) {
for (const docAction of [...undo].reverse()) { metaDocData.receiveAction(docAction); }
}
let meta = {} as {[key: string]: TableDataAction};
// Metadata is stored as a hash of TableDataActions.
for (const tableId of STRUCTURAL_TABLES) {
meta[tableId] = cloneDeep(metaDocData.getTable(tableId)!.getTableDataAction());
}
// Now step forward, tracking metadata and rules through any changes that occur.
const steps = new Array<MetaStep>();
let ruler = this._ruler;
if (applied) {
// Rules may have changed - back them off to a copy of their original state.
ruler = new Ruler(this);
await ruler.update(metaDocData);
}
let replaceRuler = false;
for (const docAction of docActions) {
const tableId = getTableId(docAction);
const step: MetaStep = {action: docAction};
step.metaBefore = meta;
if (STRUCTURAL_TABLES.has(tableId)) {
metaDocData.receiveAction(docAction);
// make shallow copy of all tables
meta = {...meta};
// replace table just modified with a deep copy
meta[tableId] = cloneDeep(metaDocData.getTable(tableId)!.getTableDataAction());
}
step.metaAfter = meta;
// replaceRuler logic avoids updating rules between paired changes of resources and rules.
if (actionHasRuleChange(docAction)) {
replaceRuler = true;
} else if (replaceRuler) {
ruler = new Ruler(this);
await ruler.update(metaDocData);
replaceRuler = false;
}
step.ruler = ruler;
step.attachmentColumns = getAttachmentColumns(metaDocData);
steps.push(step);
}
return steps;
}
/**
* Return any permitted parts of an action. A completely forbidden
* action results in an empty list. Forbidden columns and rows will
* be stripped from a returned action. Rows with forbidden cells are
* extracted and returned in distinct actions (since they will have
* a distinct set of columns).
*
* This method should only be called with data actions, and will throw
* for anything else.
*/
private async _prefilterDocAction(cursor: ActionCursor): Promise<DocAction[]> {
const {action, docSession} = cursor;
const tableId = getTableId(action);
const permInfo = await this._getStepAccess(cursor);
const tableAccess = permInfo.getTableAccess(tableId);
const accessCheck = await this._getAccessForActionType(docSession, action, 'check');
const access = accessCheck.get(tableAccess);
if (access === 'deny') {
// Filter out this action entirely.
return [];
} else if (access === 'allow') {
// Retain this action entirely.
return [action];
} else if (access === 'mixedColumns') {
// Retain some or all columns entirely.
const act = this._pruneColumns(action, permInfo, tableId, accessCheck);
return act ? [act] : [];
}
// The remainder is the mixed condition.
const {rowsBefore, rowsAfter} = await this._getRowsForRecAndNewRec(cursor);
const {censoredRows, filteredAction} = await this._filterRowsAndCells({...cursor, action: cloneDeep(action)},
rowsBefore, rowsAfter, accessCheck,
{allowRowRemoval: true});
if (filteredAction === null) {
return [];
}
if (!isDataAction(filteredAction)) {
throw new Error('_prefilterDocAction called with unexpected action');
}
if (isRemoveRecordAction(filteredAction)) {
// removals do not mention columns or cells, so no further complications.
return [filteredAction];
}
// Strip any forbidden columns.
this._filterColumns(
filteredAction[3],
(colId) => accessCheck.get(permInfo.getColumnAccess(tableId, colId)) !== 'deny');
if (censoredRows.size === 0) {
// no cell censorship, so no further complications.
return [filteredAction];
}
return filterColValues(filteredAction, (idx) => censoredRows.has(idx), gristTypes.isCensored);
}
/**
* Tailor the information about a change reported to a given client. The action passed in
* is never modified. The actions output may differ in the following ways:
* - Tables, columns or rows may be omitted if the client does not have access to them.
* - Columns in structural metadata tables may be cleared if the client does not have
* access to the resources they relate to.
* - Columns in the _grist_Views table may be cleared or uncleared depending on changes
* in other metadata tables.
* - Rows may be inserted if the client newly acquires access to them via an update.
* TODO: I think that column rules controlling READ access using rec are not fully supported
* yet. They work on first load, but if READ access is lost/gained updates won't be made.
*/
private async _filterOutgoingDocAction(cursor: ActionCursor): Promise<ActionCursor[]> {
const {action} = cursor;
const tableId = getTableId(action);
let results: DocAction[] = [];
if (tableId.startsWith('_grist')) {
// Granular access rules don't apply to metadata directly, instead there
// is a process of censorship (see later in this method).
results = [action];
} else {
const permInfo = await this._getStepAccess(cursor);
const tableAccess = permInfo.getTableAccess(tableId);
const access = this.getReadPermission(tableAccess);
const readAccessCheck = this._readAccessCheck(cursor.docSession);
if (access === 'deny') {
// filter out this data.
} else if (access === 'allow') {
results.push(action);
} else if (access === 'mixedColumns') {
const act = this._pruneColumns(action, permInfo, tableId, readAccessCheck);
if (act) { results.push(act); }
} else {
// The remainder is the mixed condition.
for (const act of await this._pruneRows(cursor)) {
const prunedAct = this._pruneColumns(act, permInfo, tableId, readAccessCheck);
if (prunedAct) { results.push(prunedAct); }
}
}
}
const secondPass: DocAction[] = [];
for (const act of results) {
if (STRUCTURAL_TABLES.has(getTableId(act)) && isDataAction(act)) {
await this._filterOutgoingStructuralTables(cursor, act, secondPass);
} else {
secondPass.push(act);
}
}
return secondPass.map(act => ({ ...cursor, action: act }));
}
private async _filterOutgoingStructuralTables(cursor: ActionCursor, act: DataAction, results: DocAction[]) {
// Filter out sensitive columns from tables.
const permissionInfo = await this._getStepAccess(cursor);
const step = await this._getMetaStep(cursor);
if (!step.metaAfter) { throw new Error('missing metadata'); }
act = cloneDeep(act); // Don't change original action.
const ruler = await this._getRuler(cursor);
const censor = new CensorshipInfo(permissionInfo,
ruler.ruleCollection,
step.metaAfter,
await this.hasAccessRulesPermission(cursor.docSession));
if (censor.apply(act)) {
results.push(act);
}
// There's a wrinkle to deal with. If we just added or removed a section, we need to
// reconsider whether the view containing it is visible.
if (getTableId(act) === '_grist_Views_section') {
if (!step.metaBefore) { throw new Error('missing prior metadata'); }
const censorBefore = new CensorshipInfo(permissionInfo,
ruler.ruleCollection,
step.metaBefore,
await this.hasAccessRulesPermission(cursor.docSession));
// For all views previously censored, if they are now uncensored,
// add an UpdateRecord to expose them.
for (const v of censorBefore.censoredViews) {
if (!censor.censoredViews.has(v)) {
const table = step.metaAfter._grist_Views;
const idx = table[2].indexOf(v);
const name = table[3].name[idx];
results.push(['UpdateRecord', '_grist_Views', v, {name}]);
}
}
// For all views currently censored, if they were previously uncensored,
// add an UpdateRecord to censor them.
for (const v of censor.censoredViews) {
if (!censorBefore.censoredViews.has(v)) {
results.push(['UpdateRecord', '_grist_Views', v, {name: ''}]);
}
}
}
}
private async _checkIncomingDocAction(cursor: ActionCursor): Promise<void> {
await this._checkIncomingAttachmentChanges(cursor);
const {action, docSession} = cursor;
const accessCheck = await this._getAccessForActionType(docSession, action, 'fatal');
const tableId = getTableId(action);
const permInfo = await this._getStepAccess(cursor);
const tableAccess = permInfo.getTableAccess(tableId);
const access = accessCheck.get(tableAccess);
if (access === 'allow') { return; }
if (access === 'mixed') {
// Deal with row-level access for the mixed condition.
await this._checkRows(cursor, accessCheck);
}
// Somewhat abusing prune method by calling it with an access function that
// throws on denial.
this._pruneColumns(action, permInfo, tableId, accessCheck);
}
/**
* Take a look at the DocAction and see if it might allow the user to
* introduce attachment ids into a cell. If so, make sure the user
* has the right to access any attachments mentioned.
*/
private async _checkIncomingAttachmentChanges(cursor: ActionCursor): Promise<void> {
const {docSession} = cursor;
const attIds = await this._gatherAttachmentChanges(cursor);
for (const attId of attIds) {
if (!await this.isAttachmentUploadedByUser(docSession, attId) &&
!await this.findAttachmentCellForUser(docSession, attId)) {
throw new ErrorWithCode('ACL_DENY', 'Cannot access attachment', {
status: 403,
});
}
}
}
/**
* If user doesn't have sufficient rights, rewrite any attachment information
* as follows:
* - Remove data actions (other than [Bulk]RemoveRecord) on the _grist_Attachments table
* - Gather any attachment ids mentioned in data actions
* - Prepend a BulkAddRecord for _grist_Attachments giving metadata for the attachments
* This will result in metadata being sent to clients more than necessary,
* but saves us keeping track of which clients already know about which
* attachments.
* We don't make any particular effort to retract attachment metadata from
* clients if they lose access to it later. They won't have access to the
* content of the attachment, and will lose metadata on a document reload.
*/
private async _filterOutgoingAttachments(cursors: ActionCursor[]) {
if (cursors.length === 0) { return []; }
const docSession = cursors[0].docSession;
if (!await this.needAttachmentControl(docSession)) {
return cursors;
}
const result = [] as ActionCursor[];
const attIds = new Set<number>();
for (const cursor of cursors) {
const changes = await this._gatherAttachmentChanges(cursor);
// We assume here that ACL rules were already applied and columns were
// either removed or censored.
// Gather all attachment ids stored in user tables.
for (const attId of changes) {
attIds.add(attId);
}
const {action} = cursor;
// Remove any additions or updates to the _grist_Attachments table.
if (!isDataAction(action) || isRemoveRecordAction(action) || getTableId(action) !== '_grist_Attachments') {
result.push(cursor);
}
}
// We removed all actions that created attachments, now send all attachments metadata
// we currently have that are related to actions being broadcast.
if (attIds.size > 0) {
const act = this._docData.getMetaTable('_grist_Attachments')
.getBulkAddRecord([...attIds]);
result.unshift({
action: act,
docSession,
// For access control purposes, this new action will be under the
// same access rules as the first DocAction.
actionIdx: cursors[0].actionIdx,
});
}
return result;
}
private async _gatherAttachmentChanges(cursor: ActionCursor): Promise<Set<number>> {
const empty = new Set<number>();
const options = this._activeBundle?.options;
if (options?.fromOwnHistory && options.oldestSource &&
Date.now() - options.oldestSource < HISTORICAL_ATTACHMENT_OWNERSHIP_PERIOD) {
return empty;
}
const {action, docSession} = cursor;
if (!isDataAction(action)) { return empty; }
if (isRemoveRecordAction(action)) { return empty; }
const tableId = getTableId(action);
const step = await this._getMetaStep(cursor);
const attachmentColumns = step.attachmentColumns;
if (!attachmentColumns) { return empty; }
const ac = attachmentColumns.get(tableId);
if (!ac) { return empty; }
const colIds = getColIdsFromDocAction(action) || [];
if (!colIds.some(colId => ac.has(colId))) { return empty; }
if (!await this.needAttachmentControl(docSession)) { return empty; }
return gatherAttachmentIds(attachmentColumns, action);
}
private async _getRuler(cursor: ActionCursor) {
if (cursor.actionIdx === null) { return this._ruler; }
const step = await this._getMetaStep(cursor);
return step.ruler || this._ruler;
}
private async _getStepAccess(cursor: ActionCursor) {
if (!this._activeBundle) { throw new Error('no active bundle'); }
if (this._activeBundle.hasAnyRuleChange) {
const step = await this._getMetaStep(cursor);
if (step.ruler) { return step.ruler.getAccess(cursor.docSession); }
}
// No rule changes!
return this._getAccess(cursor.docSession);
}
private async _getStep(cursor: ActionCursor) {
if (cursor.actionIdx === null) { throw new Error('No step available'); }
const steps = await this._getSteps();
return steps[cursor.actionIdx];
}
private async _getMetaStep(cursor: ActionCursor) {
if (cursor.actionIdx === null) { throw new Error('No step available'); }
const steps = await this._getMetaSteps();
return steps[cursor.actionIdx];
}
// Get an AccessCheck appropriate for the specific action.
// TODO: deal with ReplaceTableData, which both deletes and creates rows.
private async _getAccessForActionType(docSession: OptDocSession, a: DocAction,
severity: 'check'|'fatal'): Promise<IAccessCheck> {
if (this._hasExceptionalFullAccess(docSession)) {
return dummyAccessCheck;
}
const tableId = getTableId(a);
if (tableId.startsWith('_grist') && tableId !== '_grist_Cells') {
if (tableId === '_grist_Attachments') {
// If the back end is adding/removing an attachment, all
// necessary authentication has happened, and we can go ahead
// and do it. Perhaps the back end should just use an
// exceptional session for this, rather than a special
// flag. That would change attribution of the action in the
// log, so I stuck with a flag, but I'm not sure if
// attribution is particularly useful in this case.
if (this._activeBundle?.options?.attachment) {
return dummyAccessCheck;
}
// Users cannot take actions on _grist_Attachments through the regular
// action interface.
throw new Error('_grist_Attachments modification is not allowed');
}
// Actions on any metadata table currently require the schemaEdit flag.
// Exception: the cell info table, which needs to be reworked to be compatible
// with granular access.
// Another exception: ensure owners always have full access to ACL tables, so they
// can change rules and don't get stuck.
if (isAclTable(tableId) && await this.isOwner(docSession)) {
return dummyAccessCheck;
}
return accessChecks[severity].schemaEdit;
} else if (a[0] === 'UpdateRecord' || a[0] === 'BulkUpdateRecord') {
return accessChecks[severity].update;
} else if (a[0] === 'RemoveRecord' || a[0] === 'BulkRemoveRecord') {
return accessChecks[severity].delete;
} else if (a[0] === 'AddRecord' || a[0] === 'BulkAddRecord') {
return accessChecks[severity].create;
} else {
return accessChecks[severity].schemaEdit;
}
}
/**
* Filter outgoing actions and include or remove cell information from _grist_Cells.
*/
private async _filterOutgoingCellInfo(docSession: OptDocSession, before: DocAction[], after: DocAction[]) {
// Rewrite bundle, simplifying all actions that are touching cell metadata.
const cellView = new CellData(this._docData);
const patch = cellView.generatePatch(before);
// If there is nothing to do, just return after state.
if (!patch) { return after; }
// Now remove all action that modify cell metadata from after.
// We will use the patch to reconstruct the cell metadata.
const result = after.filter(action => !isCellDataAction(action));
// Prepare checker, we need to use checker from the last step.
const cursor = {
docSession,
action: before[before.length - 1],
actionIdx: before.length - 1
};
const ruler = await this._getRuler(cursor);
const permInfo = await ruler.getAccess(docSession);
const inputs = await this.inputs(docSession);
// Cache some data, as they are checked.
const readRows = memoize(this._fetchQueryFromDB.bind(this));
const hasAccess = async (cell: SingleCell) => {
// First check table access, maybe table is hidden.
const tableAccess = permInfo.getTableAccess(cell.tableId);
const access = this.getReadPermission(tableAccess);
if (access === 'deny') { return false; }
// Check, if table is fully allowed (no ACL column/rows rules).
if (access === 'allow') { return true; }
// Maybe there are only rules that hides this column completely.
if (access === 'mixedColumns') {
const collAccess = this.getReadPermission(permInfo.getColumnAccess(cell.tableId, cell.colId));
if (collAccess === 'deny') { return false; }
if (collAccess === 'allow') { return true; }
}
// Probably there are rules at the cell level, check them.
const rows = await readRows({
tableId: cell.tableId,
filters: { id: [cell.rowId] }
});
// Make sure we have row.
if (!rows || rows[2].length === 0) {
if (cell.rowId) {
return false;
}
}
const rec = rows ? new RecordView(rows, 0) : undefined;
const input: PredicateFormulaInput = {...inputs, rec, newRec: rec};
const rowPermInfo = new PermissionInfo(ruler.ruleCollection, input);
const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;
if (rowAccess === 'deny') { return false; }
if (rowAccess !== 'allow') {
const colAccess = rowPermInfo.getColumnAccess(cell.tableId, cell.colId).perms.read;
if (colAccess === 'deny') { return false; }
}
return true;
};
// Now censor the patch, so it only contains cells content that user has access to.
await cellView.censorCells(patch, (cell) => hasAccess(cell));
// And append it to the result.
result.push(...patch);
return result;
}
/**
* Tests if the user can modify cell's data.
*/
private async _canApplyCellActions(currentUser: User, userIsOwner: boolean) {
// Owner can modify all comments, without exceptions.
if (userIsOwner) {
return;
}
if (!this._activeBundle) { throw new Error('no active bundle'); }
const {docActions, docSession} = this._activeBundle;
const snapShot = await this.createSnapshotWithCells();
const cellView = new CellData(snapShot);
await cellView.applyAndCheck(
docActions,
userIsOwner,
this._ruler.haveRules(),
currentUser.UserRef || '',
(cell, state) => this.hasCellAccess(docSession, cell, state),
);
}
private _createCellAccess(docSession: OptDocSession, docData?: DocData) {
return new CellAccessHelper(this, this._ruler, docSession, this._fetchQueryFromDB, docData);
}
(core) support adding user characteristic tables for granular ACLs Summary: This is a prototype for expanding the conditions that can be used in granular ACLs. When processing ACLs, the following variables (called "characteristics") are now available in conditions: * UserID * Email * Name * Access (owners, editors, viewers) The set of variables can be expanded by adding a "characteristic" clause. This is a clause which specifies: * A tableId * The name of an existing characteristic * A colId The effect of the clause is to expand the available characteristics with all the columns in the table, with values taken from the record where there is a match between the specified characteristic and the specified column. Existing clauses are generalized somewhat to demonstrate and test the use these variables. That isn't the main point of this diff though, and I propose to leave generalizing+systematizing those clauses for a future diff. Issues I'm not dealing with here: * How clauses combine. (The scope on GranularAccessRowClause is a hack to save me worrying about that yet). * The full set of matching methods we'll allow. * Refreshing row access in clients when the tables mentioned in characteristic tables change. * Full CRUD permission control. * Default rules (part of combination). * Reporting errors in access rules. That said, with this diff it is possible to e.g. assign a City to editors by their email address or name, and have only rows for those Cities be visible in their client. Ability to modify those rows, and remain updates about them, remains under incomplete control. Test Plan: added tests Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2642
2020-10-19 14:25:21 +00:00
}
/**
* A snapshots of rules and permissions at during one of more steps within a bundle.
*/
export class Ruler {
// The collection of all rules, with helpful accessors.
public ruleCollection = new ACLRuleCollection();
// Cache of PermissionInfo associated with the given docSession. It's a WeakMap, so should allow
// both to be garbage-collected once docSession is no longer in use.
private _permissionInfoMap = new WeakMap<OptDocSession, Promise<PermissionInfo>>();
public constructor(private _owner: RulerOwner) {}
public async getAccess(docSession: OptDocSession): Promise<PermissionInfo> {
// TODO The intent of caching is to avoid duplicating rule evaluations while processing a
// single request. Caching based on docSession is riskier since those persist across requests.
return getSetMapValue(this._permissionInfoMap as Map<OptDocSession, Promise<PermissionInfo>>, docSession,
async () => new PermissionInfo(this.ruleCollection, await this._owner.inputs(docSession)));
}
public flushAccess(docSession: OptDocSession) {
this._permissionInfoMap.delete(docSession);
}
/**
* Update granular access from DocData.
*/
public async update(docData: DocData) {
await this.ruleCollection.update(docData, {
log,
compile: compilePredicateFormula,
enrichRulesForImplementation: true,
});
// Also clear the per-docSession cache of rule evaluations.
this.clearCache();
}
public clearCache() {
this._permissionInfoMap = new WeakMap();
}
public haveRules() {
return this.ruleCollection.haveRules();
}
}
export interface RulerOwner {
getUser(docSession: OptDocSession): Promise<User>;
inputs(docSession: OptDocSession): Promise<PredicateFormulaInput>;
}
/**
* Information about a single step within a bundle. We cache this information to share
* when filtering output to several clients.
*/
export interface ActionStep {
action: DocAction;
rowsBefore: TableDataAction|undefined; // only defined for actions modifying rows
rowsAfter: TableDataAction|undefined; // only defined for actions modifying rows
rowsLast?: TableDataAction; // cached calculation of where to point "newRec"
}
export interface MetaStep {
action: DocAction;
metaBefore?: {[key: string]: TableDataAction}; // cached structural metadata before action
metaAfter?: {[key: string]: TableDataAction}; // cached structural metadata after action
ruler?: Ruler; // rules at this step
attachmentColumns?: AttachmentColumns; // attachment columns after this step
}
/**
* A pointer to a particular step within a bundle for a particular session.
*/
interface ActionCursor {
action: DocAction;
docSession: OptDocSession;
actionIdx: number|null; // an index into where we are within the original
// DocActions, for access control purposes.
// Used for referencing a cache of intermediate
// access control state.
}
/**
* A read-write view of a DataAction, for use in censorship.
*/
class RecordEditor implements InfoEditor {
private _rows: number[];
private _bulk: boolean;
private _data: ColValues | BulkColValues;
public constructor(public data: DataAction, public index: number|undefined,
public optional: boolean) {
const rows = data[2];
this._bulk = Array.isArray(rows);
this._rows = Array.isArray(rows) ? rows : [rows];
this._data = data[3] || {};
}
public get(colId: string): CellValue {
if (this.index === undefined) { return null; }
if (colId === 'id') {
return this._rows[this.index];
}
return this._bulk ?
(this._data as BulkColValues)[colId][this.index] :
(this._data as ColValues)[colId];
}
public set(colId: string, val: CellValue): this {
if (this.index === undefined) { throw new Error('cannot set value of non-existent cell'); }
if (colId === 'id') { throw new Error('cannot change id'); }
if (this.optional && !(colId in this._data)) { return this; }
if (this._bulk) {
(this._data as BulkColValues)[colId][this.index] = val;
} else {
(this._data as ColValues)[colId] = val;
}
return this;
}
public toJSON() {
if (this.index === undefined) { return {}; }
const results: {[key: string]: any} = {};
for (const key of Object.keys(this._data)) {
results[key] = this.get(key);
}
return results;
}
}
(core) support adding user characteristic tables for granular ACLs Summary: This is a prototype for expanding the conditions that can be used in granular ACLs. When processing ACLs, the following variables (called "characteristics") are now available in conditions: * UserID * Email * Name * Access (owners, editors, viewers) The set of variables can be expanded by adding a "characteristic" clause. This is a clause which specifies: * A tableId * The name of an existing characteristic * A colId The effect of the clause is to expand the available characteristics with all the columns in the table, with values taken from the record where there is a match between the specified characteristic and the specified column. Existing clauses are generalized somewhat to demonstrate and test the use these variables. That isn't the main point of this diff though, and I propose to leave generalizing+systematizing those clauses for a future diff. Issues I'm not dealing with here: * How clauses combine. (The scope on GranularAccessRowClause is a hack to save me worrying about that yet). * The full set of matching methods we'll allow. * Refreshing row access in clients when the tables mentioned in characteristic tables change. * Full CRUD permission control. * Default rules (part of combination). * Reporting errors in access rules. That said, with this diff it is possible to e.g. assign a City to editors by their email address or name, and have only rows for those Cities be visible in their client. Ability to modify those rows, and remain updates about them, remains under incomplete control. Test Plan: added tests Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2642
2020-10-19 14:25:21 +00:00
/**
* Cache information about user attributes.
(core) support adding user characteristic tables for granular ACLs Summary: This is a prototype for expanding the conditions that can be used in granular ACLs. When processing ACLs, the following variables (called "characteristics") are now available in conditions: * UserID * Email * Name * Access (owners, editors, viewers) The set of variables can be expanded by adding a "characteristic" clause. This is a clause which specifies: * A tableId * The name of an existing characteristic * A colId The effect of the clause is to expand the available characteristics with all the columns in the table, with values taken from the record where there is a match between the specified characteristic and the specified column. Existing clauses are generalized somewhat to demonstrate and test the use these variables. That isn't the main point of this diff though, and I propose to leave generalizing+systematizing those clauses for a future diff. Issues I'm not dealing with here: * How clauses combine. (The scope on GranularAccessRowClause is a hack to save me worrying about that yet). * The full set of matching methods we'll allow. * Refreshing row access in clients when the tables mentioned in characteristic tables change. * Full CRUD permission control. * Default rules (part of combination). * Reporting errors in access rules. That said, with this diff it is possible to e.g. assign a City to editors by their email address or name, and have only rows for those Cities be visible in their client. Ability to modify those rows, and remain updates about them, remains under incomplete control. Test Plan: added tests Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2642
2020-10-19 14:25:21 +00:00
*/
class UserAttributes {
public rows: {[clauseName: string]: InfoView} = {};
public override?: UserOverride;
}
interface IAccessCheck {
get(ps: PermissionSetWithContext): string;
throwIfDenied(ps: PermissionSetWithContext): void;
throwIfNotFullyAllowed(ps: PermissionSetWithContext): void;
}
class AccessCheck implements IAccessCheck {
constructor(public access: 'update'|'delete'|'create'|'schemaEdit'|'read',
public severity: 'check'|'fatal') {
}
public get(ps: PermissionSetWithContext): string {
const result = ps.perms[this.access];
if (result !== 'deny' || this.severity !== 'fatal') { return result; }
this.throwIfDenied(ps);
return result;
}
public throwIfDenied(ps: PermissionSetWithContext): void {
const result = ps.perms[this.access];
if (result !== 'deny') { return; }
this._throwError(ps);
}
public throwIfNotFullyAllowed(ps: PermissionSetWithContext): void {
const result = ps.perms[this.access];
if (result === 'allow') { return; }
this._throwError(ps);
}
private _throwError(ps: PermissionSetWithContext): void {
const memos = ps.getMemos()[this.access];
const label =
this.access === 'schemaEdit' ? 'structure' :
this.access;
throw new ErrorWithCode('ACL_DENY', `Blocked by ${ps.ruleType} ${label} access rules`, {
memos,
status: 403
});
}
}
export const accessChecks = {
check: fromPairs(ALL_PERMISSION_PROPS.map(prop => [prop, new AccessCheck(prop, 'check')])),
fatal: fromPairs(ALL_PERMISSION_PROPS.map(prop => [prop, new AccessCheck(prop, 'fatal')])),
};
// This AccessCheck allows everything.
const dummyAccessCheck: IAccessCheck = {
get() { return 'allow'; },
throwIfDenied() {},
throwIfNotFullyAllowed() {}
};
/**
* Helper class to calculate access for a set of cells in bulk. Used for initial
* access check for a whole _grist_Cell table. Each cell can belong to a different
* table and row, so here we will avoid loading rows multiple times and checking
* the table access multiple time.
*/
class CellAccessHelper {
private _tableAccess: Map<string, boolean> = new Map();
private _rowPermInfo: Map<string, Map<number, PermissionInfo>> = new Map();
private _rows: Map<string, TableDataAction> = new Map();
private _inputs!: PredicateFormulaInput;
constructor(
private _granular: GranularAccess,
private _ruler: Ruler,
private _docSession: OptDocSession,
private _fetchQueryFromDB?: (query: ServerQuery) => Promise<TableDataAction>,
private _state?: DocData,
) { }
/**
* Resolves access for all cells, and save the results in the cache.
*/
public async calculate(cells: SingleCell[]) {
this._inputs = await this._granular.inputs(this._docSession);
const tableIds = new Set(cells.map(cell => cell.tableId));
for (const tableId of tableIds) {
this._tableAccess.set(tableId, await this._granular.hasTableAccess(this._docSession, tableId));
if (this._tableAccess.get(tableId)) {
const rowIds = new Set(cells.filter(cell => cell.tableId === tableId).map(cell => cell.rowId));
const rows = await this._getRows(tableId, rowIds);
for(const [idx, rowId] of rows[2].entries()) {
if (rowIds.has(rowId) === false) { continue; }
const rec = new RecordView(rows, idx);
const input: PredicateFormulaInput = {...this._inputs, rec, newRec: rec};
const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);
if (!this._rowPermInfo.has(tableId)) {
this._rowPermInfo.set(tableId, new Map());
}
this._rowPermInfo.get(tableId)!.set(rows[2][idx], rowPermInfo);
this._rows.set(tableId, rows);
}
}
}
}
/**
* Checks if user has a read access to a particular cell. Needs to be called after calculate().
*/
public hasAccess(cell: SingleCell) {
const rowPermInfo = this._rowPermInfo.get(cell.tableId)?.get(cell.rowId);
if (!rowPermInfo) { return true; }
const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;
if (rowAccess === 'deny') { return true; }
if (rowAccess !== 'allow') {
const colAccess = rowPermInfo.getColumnAccess(cell.tableId, cell.colId).perms.read;
if (colAccess === 'deny') { return true; }
}
const colValues = this._rows.get(cell.tableId);
if (!colValues || !(cell.colId in colValues[3])) { return true; }
return false;
}
private async _getRows(tableId: string, rowIds: Set<number>) {
if (this._state) {
const rows = this._state.getTable(tableId)!.getTableDataAction();
return rows;
}
if (this._fetchQueryFromDB) {
return await this._fetchQueryFromDB({
tableId,
filters: { id: [...rowIds] }
});
}
return ['TableData', tableId, [], {}] as TableDataAction;
}
}
/**
* Manage censoring metadata.
*
* For most metadata, censoring means blanking out certain fields, rather than removing rows,
* (because the latter was too big of a change). In particular, these changes are relied on by
* other code:
*
* - Censored tables (from _grist_Tables) have cleared tableId field. To check for it, use the
* isTableCensored() helper in app/common/isHiddenTable.ts. This is used by exports to Excel.
*/
export class CensorshipInfo {
public censoredTables = new Set<number>();
public censoredSections = new Set<number>();
public censoredViews = new Set<number>();
public censoredColumns = new Set<number>();
public censoredFields = new Set<number>();
public censoredComments = new Set<number>();
public censored = {
_grist_Tables: this.censoredTables,
_grist_Tables_column: this.censoredColumns,
_grist_Views: this.censoredViews,
_grist_Views_section: this.censoredSections,
_grist_Views_section_field: this.censoredFields,
_grist_Cells: this.censoredComments,
};
public constructor(permInfo: PermissionInfo,
ruleCollection: ACLRuleCollection,
tables: {[key: string]: TableDataAction},
private _canViewACLs: boolean,
cellAccessInfo?: CellAccessHelper) {
// Collect a list of censored columns (by "<tableRef> <colId>").
const columnCode = (tableRef: number, colId: string) => `${tableRef} ${colId}`;
const censoredColumnCodes: Set<string> = new Set();
const tableRefToTableId: Map<number, string> = new Map();
const tableRefToIndex: Map<number, number> = new Map();
const columnRefToColId: Map<number, string> = new Map();
const uncensoredTables: Set<number> = new Set();
// Scan for forbidden tables.
let rec = new RecordView(tables._grist_Tables, undefined);
let ids = getRowIdsFromDocAction(tables._grist_Tables);
for (let idx = 0; idx < ids.length; idx++) {
rec.index = idx;
const tableId = rec.get('tableId') as string;
const tableRef = ids[idx];
tableRefToTableId.set(tableRef, tableId);
tableRefToIndex.set(tableRef, idx);
const tableAccess = permInfo.getTableAccess(tableId);
if (tableAccess.perms.read === 'deny') {
this.censoredTables.add(tableRef);
} else if (tableAccess.perms.read === 'allow') {
uncensoredTables.add(tableRef);
}
}
// Scan for forbidden columns.
ids = getRowIdsFromDocAction(tables._grist_Tables_column);
rec = new RecordView(tables._grist_Tables_column, undefined);
for (let idx = 0; idx < ids.length; idx++) {
rec.index = idx;
const tableRef = rec.get('parentId') as number;
const colId = rec.get('colId') as string;
const colRef = ids[idx];
columnRefToColId.set(colRef, colId);
if (uncensoredTables.has(tableRef)) { continue; }
const tableId = tableRefToTableId.get(tableRef);
if (!tableId) { throw new Error('table not found'); }
if (this.censoredTables.has(tableRef) ||
(colId !== 'manualSort' && permInfo.getColumnAccess(tableId, colId).perms.read === 'deny')) {
censoredColumnCodes.add(columnCode(tableRef, colId));
}
if (isTransformColumn(colId) && permInfo.getColumnAccess(tableId, colId).perms.schemaEdit === 'deny') {
censoredColumnCodes.add(columnCode(tableRef, colId));
}
}
// Collect a list of all sections and views containing a table to which the user has no access.
rec = new RecordView(tables._grist_Views_section, undefined);
ids = getRowIdsFromDocAction(tables._grist_Views_section);
for (let idx = 0; idx < ids.length; idx++) {
rec.index = idx;
if (!this.censoredTables.has(rec.get('tableRef') as number)) { continue; }
const parentId = rec.get('parentId') as number;
if (parentId) { this.censoredViews.add(parentId); }
this.censoredSections.add(ids[idx]);
}
// Collect a list of all columns from tables to which the user has no access.
rec = new RecordView(tables._grist_Tables_column, undefined);
ids = getRowIdsFromDocAction(tables._grist_Tables_column);
for (let idx = 0; idx < ids.length; idx++) {
rec.index = idx;
const parentId = rec.get('parentId') as number;
if (this.censoredTables.has(parentId) ||
censoredColumnCodes.has(columnCode(parentId, rec.get('colId') as string))) {
this.censoredColumns.add(ids[idx]);
}
}
// Collect a list of all fields from sections to which the user has no access.
rec = new RecordView(tables._grist_Views_section_field, undefined);
ids = getRowIdsFromDocAction(tables._grist_Views_section_field);
for (let idx = 0; idx < ids.length; idx++) {
rec.index = idx;
if (!this.censoredSections.has(rec.get('parentId') as number) &&
!this.censoredColumns.has(rec.get('colRef') as number)) { continue; }
this.censoredFields.add(ids[idx]);
}
// Now undo some of the above...
// Specifically, when a summary table is not censored, uncensor the source table's raw view section,
// so that the user can see the source table's title,
// which is used to construct the summary table's title. The section's fields remain censored.
// This would also be a sensible place to uncensor the source tableId, but that causes other problems.
rec = new RecordView(tables._grist_Tables, undefined);
ids = getRowIdsFromDocAction(tables._grist_Tables);
for (let idx = 0; idx < ids.length; idx++) {
rec.index = idx;
const tableRef = ids[idx];
const sourceTableRef = rec.get('summarySourceTable') as number;
const sourceTableIndex = tableRefToIndex.get(sourceTableRef);
if (
this.censoredTables.has(tableRef) ||
!sourceTableRef ||
sourceTableIndex === undefined ||
!this.censoredTables.has(sourceTableRef)
) { continue; }
rec.index = sourceTableIndex;
const rawViewSectionRef = rec.get('rawViewSectionRef') as number;
this.censoredSections.delete(rawViewSectionRef);
}
// Collect a list of all cells metadata to which the user has no access.
rec = new RecordView(tables._grist_Cells, undefined);
ids = tables._grist_Cells ? getRowIdsFromDocAction(tables._grist_Cells) : [];
for (let idx = 0; idx < ids.length; idx++) {
rec.index = idx;
const isTableCensored = () => this.censoredTables.has(rec.get('tableRef') as number);
const isColumnCensored = () => this.censoredColumns.has(rec.get('colRef') as number);
const isCellCensored = () => {
if (!cellAccessInfo) { return false; }
const cell = {
tableId: tableRefToTableId.get(rec.get('tableRef') as number)!,
colId: columnRefToColId.get(rec.get('colRef') as number)!,
rowId: rec.get('rowId') as number
};
return !cell.tableId || !cell.colId || cellAccessInfo.hasAccess(cell);
};
if (isTableCensored() || isColumnCensored() || isCellCensored()) {
this.censoredComments.add(ids[idx]);
}
}
}
public apply(a: DataAction) {
const tableId = getTableId(a);
if (!STRUCTURAL_TABLES.has(tableId)) { return true; }
return this.filter(a);
}
public filter(a: DataAction) {
const tableId = getTableId(a);
if (!(tableId in this.censored)) {
if (!this._canViewACLs && a[0] === 'TableData') {
a[2] = [];
a[3] = {};
}
return this._canViewACLs;
}
const rec = new RecordEditor(a, undefined, true);
const method = getCensorMethod(getTableId(a));
const censoredRows = (this.censored as any)[tableId] as Set<number>;
const ids = getRowIdsFromDocAction(a);
for (const [index, id] of ids.entries()) {
if (censoredRows.has(id)) {
rec.index = index;
method(rec);
}
}
return true;
}
}
function getCensorMethod(tableId: string): (rec: RecordEditor) => void {
switch (tableId) {
case '_grist_Tables':
return rec => rec.set('tableId', '');
case '_grist_Views':
return rec => rec.set('name', '');
case '_grist_Views_section':
return rec => rec.set('title', '').set('tableRef', 0);
case '_grist_Tables_column':
return rec => rec.set('label', '').set('colId', '').set('widgetOptions', '')
.set('formula', '').set('type', 'Any').set('parentId', 0);
case '_grist_Views_section_field':
return rec => rec.set('widgetOptions', '').set('filter', '').set('parentId', 0);
case '_grist_ACLResources':
return rec => rec;
case '_grist_ACLRules':
return rec => rec;
(core) add initial support for special shares Summary: This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules. It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with: * When reading rules, if there are shares, extra rules are added. * If there are shares, all rules are made conditional on a "ShareRef" user property. * "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share. There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do: ``` gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}]) ``` If you look at the home db now there should be something in the `shares` table: ``` $ sqlite3 -table landing.db "select * from shares" +----+------------------------+------------------------+--------------+---------+ | id | key | doc_id | link_id | options | +----+------------------------+------------------------+--------------+---------+ | 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... | +----+------------------------+------------------------+--------------+---------+ ``` If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share. E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc. To actually share some material - useful commands: ``` gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}]) gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}]) ``` For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options. I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard. Test Plan: tests added Reviewers: dsagal, georgegevoian Reviewed By: dsagal, georgegevoian Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
case '_grist_Shares':
return rec => rec;
case '_grist_Cells':
return rec => rec.set('content', [GristObjCode.Censored]).set('userRef', '');
default:
throw new Error(`cannot censor ${tableId}`);
}
}
function scanActionsRecursively<T extends DocAction|UserAction>(actions: T[],
check: (action: T) => boolean): boolean {
for (const a of actions) {
if (a[0] === 'ApplyUndoActions' || a[0] === 'ApplyDocActions') {
return scanActionsRecursively(a[1] as T[], check);
}
if (check(a)) { return true; }
}
return false;
}
async function applyToActionsRecursively(actions: (DocAction|UserAction)[],
op: (action: DocAction|UserAction) => Promise<void>): Promise<void> {
for (const a of actions) {
if (a[0] === 'ApplyUndoActions' || a[0] === 'ApplyDocActions') {
await applyToActionsRecursively(a[1] as UserAction[], op);
}
await op(a);
}
}
/**
* Takes an action, and removes certain cells from it. The action
* passed in is modified in place, and also returned as part of a list
* of derived actions.
*
* For a non-bulk action, any cell values that return true for
* shouldFilterCell are removed. For a bulk action, there's no way to
* express that in general in a single action. For a bulk action, for
* any row (identified by row index, not rowId) that returns true for
* shouldFilterRow, we remove cell values based on shouldFilterCell
* and add the row to an action with just the remaining cell values.
*
* This is by no means a general-purpose function. It is used only in
* the implementation of partial undos. If is factored out for
* testing purposes.
*
* This method could be made unnecessary if a way were created to have
* unambiguous "holes" in column value arrays, where values for some
* rows are omitted.
*/
export function filterColValues(action: DataAction,
shouldFilterRow: (idx: number) => boolean,
shouldFilterCell: (value: CellValue) => boolean): DataAction[] {
if (isRemoveRecordAction(action)) {
// removals do not have cells, so nothing to do.
return [action];
}
const colIds = Object.keys(action[3]).sort();
const colValues = action[3];
if (!isBulkAction(action)) {
for (const colId of colIds) {
if (shouldFilterCell((colValues as ColValues)[colId])) {
delete colValues[colId];
}
}
return [action];
}
const rowIds = action[2];
// For bulk operations, censored cells require us to reorganize into a set of actions
// with different columns.
const parts: Map<string, typeof action> = new Map();
let at = 0;
for (let idx = 0; idx < rowIds.length; idx++) {
if (!shouldFilterRow(idx)) {
if (idx !== at) {
// Shuffle columnar data up as we remove rows.
rowIds[at] = rowIds[idx];
for (const colId of colIds) {
(colValues as BulkColValues)[colId][at] = (colValues as BulkColValues)[colId][idx];
}
}
at++;
continue;
}
// Some censored data in this row, so move the row to an action specialized
// for the set of columns this row has.
const keys: string[] = [];
const values: BulkColValues = {};
for (const colId of colIds) {
const value = (colValues as BulkColValues)[colId][idx];
if (!shouldFilterCell(value)) {
values[colId] = [value];
keys.push(colId);
}
}
const mergedKey = keys.join(' ');
const peers = parts.get(mergedKey);
if (!peers) {
parts.set(mergedKey, [action[0], action[1], [rowIds[idx]], values]);
} else {
peers[2].push(rowIds[idx]);
for (const key of keys) {
peers[3][key].push(values[key][0]);
}
}
}
// Truncate columnar data.
rowIds.length = at;
for (const colId of colIds) {
(colValues as BulkColValues)[colId].length = at;
}
// Return all actions, in a consistent order for test purposes.
return [action, ...[...parts.keys()].sort().map(key => parts.get(key)!)];
}
export function validTableIdString(tableId: any): string {
if (typeof tableId !== 'string') { throw new Error(`Expected tableId to be a string`); }
return tableId;
}
function actionHasRuleChange(a: DocAction): boolean {
return isAclTable(getTableId(a)) || (
// Check if any helper columns have been specified while adding/updating a metadata record,
// as this will affect the result of `getHelperCols` in `ACLRuleCollection.ts` and thus the set of ACL resources.
// Note that removing a helper column doesn't directly trigger this code, but:
// - It will typically be accompanied closely by unsetting the helper column on the metadata record.
// - `getHelperCols` can handle non-existent helper columns and other similarly invalid metadata.
// - Since the column is removed, ACL restrictions on it don't really matter.
isDataAction(a)
&& ["_grist_Tables_column", "_grist_Views_section_field"].includes(getTableId(a))
&& Boolean(
a[3]?.hasOwnProperty('rules') ||
a[3]?.hasOwnProperty('displayCol')
)
);
}
/**
* Wrapper around a permission info object that overrides permissions for transform columns.
*/
class TransformColumnPermissionInfo implements IPermissionInfo {
constructor(private _inner: IPermissionInfo) {
}
public getColumnAccess(tableId: string, colId: string): MixedPermissionSetWithContext {
const access = this._inner.getColumnAccess(tableId, colId);
const isSchemaDenied = access.perms.schemaEdit === 'deny';
// If this is a transform column, it's only accessible if the user has a schemaEdit access.
if (isSchemaDenied && isTransformColumn(colId)) {
return {
...access,
perms: {
create: 'deny',
read: 'deny',
update: 'deny',
delete: 'deny',
schemaEdit: 'deny',
}
};
}
return access;
}
public getTableAccess(tableId: string): TablePermissionSetWithContext {
return this._inner.getTableAccess(tableId);
}
public getFullAccess(): MixedPermissionSetWithContext {
return this._inner.getFullAccess();
}
public getRuleCollection(): ACLRuleCollection {
return this._inner.getRuleCollection();
}
}
interface SingleCellInfo extends SingleCell {
userRef: string;
id: number;
}
/**
* Helper class that extends DocData with cell specific functions.
*/
export class CellData {
constructor(private _docData: DocData) {
}
public getCell(cellId: number) {
const row = this._docData.getMetaTable("_grist_Cells").getRecord(cellId);
return row ? this.convertToCellInfo(row) : null;
}
public getCellRecord(cellId: number) {
const row = this._docData.getMetaTable("_grist_Cells").getRecord(cellId);
return row || null;
}
/**
* Generates a patch for cell metadata. It assumes, that engine removes all
* cell metadata when cell (table/column/row) is removed and the bundle contains,
* all actions that are needed to remove the cell and cell metadata.
*/
public generatePatch(actions: DocAction[]) {
const removedCells: Set<number> = new Set();
const addedCells: Set<number> = new Set();
const updatedCells: Set<number> = new Set();
function applyCellAction(action: DataAction) {
if (isAddRecordAction(action) || isBulkAddRecord(action)) {
for(const id of getRowIdsFromDocAction(action)) {
if (removedCells.has(id)) {
removedCells.delete(id);
updatedCells.add(id);
} else {
addedCells.add(id);
}
}
} else if (isRemoveRecordAction(action) || isBulkRemoveRecord(action)) {
for(const id of getRowIdsFromDocAction(action)) {
if (addedCells.has(id)) {
addedCells.delete(id);
} else {
removedCells.add(id);
updatedCells.delete(id);
}
}
} else {
for(const id of getRowIdsFromDocAction(action)) {
if (addedCells.has(id)) {
// ignore
} else {
updatedCells.add(id);
}
}
}
}
// Scan all actions and collect all cell ids that are added, removed or updated.
// When some rows are updated, include all cells for that row. Keep track of table
// renames.
const updatedRows: Map<string, Set<number>> = new Map();
for(const action of actions) {
if (action[0] === 'RenameTable') {
updatedRows.set(action[2], updatedRows.get(action[1]) || new Set());
continue;
}
if (action[0] === 'RemoveTable') {
updatedRows.delete(action[1]);
continue;
}
if (isDataAction(action) && isCellDataAction(action)) {
applyCellAction(action);
continue;
}
if (!isDataAction(action)) { continue; }
// We don't care about new rows, as they don't have meta data at this moment.
// If regular rows are removed, we also don't care about them, as they will
// produce metadata removal.
// We only care about updates, as it might change the metadata visibility.
if (isUpdateRecord(action) || isBulkUpdateRecord(action)) {
if (getTableId(action).startsWith("_grist")) { continue; }
// Updating a row, for us means that all metadata for this row should be refreshed.
for(const rowId of getRowIdsFromDocAction(action)) {
getSetMapValue(updatedRows, getTableId(action), () => new Set()).add(rowId);
}
}
}
for(const [tableId, rowIds] of updatedRows) {
for(const {id} of this.readCells(tableId, rowIds)) {
if (addedCells.has(id) || updatedCells.has(id) || removedCells.has(id)) {
// If we have this cell id in the list of added/updated/removed cells, ignore it.
} else {
updatedCells.add(id);
}
}
}
const insert = this.generateInsert([...addedCells]);
const update = this.generateUpdate([...updatedCells]);
const removes = this.generateRemovals([...removedCells]);
const patch: DocAction[] = [insert, update, removes].filter(Boolean) as DocAction[];
return patch.length ? patch : null;
}
public async censorCells(
docActions: DocAction[],
hasAccess: (cell: SingleCellInfo) => Promise<boolean>
) {
for (const action of docActions) {
if (!isDataAction(action) || isRemoveRecordAction(action)) {
continue;
} else if (isDataAction(action) && getTableId(action) === '_grist_Cells') {
if (!isBulkAction(action)) {
const cell = this.getCell(action[2]);
if (!cell || !await hasAccess(cell)) {
action[3].content = [GristObjCode.Censored];
action[3].userRef = '';
}
} else {
for (let idx = 0; idx < action[2].length; idx++) {
const cell = this.getCell(action[2][idx]);
if (!cell || !await hasAccess(cell)) {
action[3].content[idx] = [GristObjCode.Censored];
action[3].userRef[idx] = '';
}
}
}
}
}
return docActions;
}
public convertToCellInfo(cell: MetaRowRecord<'_grist_Cells'>): SingleCellInfo {
const singleCell = {
tableId: this.getTableId(cell.tableRef) as string,
colId: this.getColId(cell.colRef) as string,
rowId: cell.rowId,
userRef: cell.userRef,
id: cell.id,
};
return singleCell;
}
public getColId(colRef: number) {
return this._docData.getMetaTable("_grist_Tables_column").getRecord(colRef)?.colId;
}
public getColRef(table: number|string, colId: string) {
const tableRef = typeof table === 'string' ? this.getTableRef(table) : table;
return this._docData.getMetaTable("_grist_Tables_column").filterRecords({colId})
.find(c => c.parentId === tableRef)?.id;
}
public getTableId(tableRef: number) {
return this._docData.getMetaTable("_grist_Tables").getRecord(tableRef)?.tableId;
}
public getTableRef(tableId: string) {
return this._docData.getMetaTable("_grist_Tables").findRow('tableId', tableId) || undefined;
}
/**
* Returns all cells for a given table and row ids.
*/
public readCells(tableId: string, rowIds: Set<number>) {
const tableRef = this.getTableRef(tableId);
const cells = this._docData.getMetaTable("_grist_Cells").filterRecords({
tableRef,
}).filter(r => rowIds.has(r.rowId));
return cells.map(this.convertToCellInfo.bind(this));
}
// Helper function that tells if a cell can be determined fully from the action itself.
// Otherwise we need to look in the docData.
public hasCellInfo(docAction: DocAction):
docAction is UpdateRecord|BulkUpdateRecord|AddRecord|BulkAddRecord {
if (!isDataAction(docAction)) { return false; }
if ((isAddRecordAction(docAction) || isUpdateRecord(docAction) || isBulkUpdateRecord(docAction))
&& docAction[3].tableRef && docAction[3].colRef && docAction[3].rowId && docAction[3].userRef) {
return true;
}
return false;
}
/**
* Checks if cell is 'attached', i.e. it has a tableRef, colRef, rowId and userRef.
*/
public isAttached(cell: SingleCellInfo) {
return Boolean(cell.tableId && cell.rowId && cell.colId && cell.userRef);
}
/**
* Reads all SingleCellInfo from docActions or from docData if action doesn't have enough enough
* information.
*/
public convertToCells(action: DocAction): SingleCellInfo[] {
if (!isDataAction(action)) { return []; }
if (getTableId(action) !== '_grist_Cells') { return []; }
const result: { tableId: string, rowId: number, colId: string, id: number, userRef: string}[] = [];
if (isBulkAction(action)) {
for (let idx = 0; idx < action[2].length; idx++) {
if (this.hasCellInfo(action)) {
result.push({
tableId: this.getTableId(action[3].tableRef[idx] as number) as string,
colId: this.getColId(action[3].colRef[idx] as number) as string,
rowId: action[3].rowId[idx] as number,
userRef: (action[3].userRef[idx] ?? '') as string,
id: action[2][idx],
});
} else {
const cellInfo = this.getCell(action[2][idx]);
if (cellInfo) {
result.push(cellInfo);
}
}
}
} else {
if (this.hasCellInfo(action)) {
result.push({
tableId: this.getTableId(action[3].tableRef as number) as string,
colId: this.getColId(action[3].colRef as number) as string,
rowId: action[3].rowId as number,
userRef: action[3].userRef as string,
id: action[2],
});
} else {
const cellInfo = this.getCell(action[2]);
if (cellInfo) {
result.push(cellInfo);
}
}
}
return result;
}
public generateInsert(ids: number[]): DataAction | null {
const action: BulkAddRecord = [
'BulkAddRecord',
'_grist_Cells',
[],
{
tableRef: [],
colRef: [],
type: [],
root: [],
content: [],
rowId: [],
userRef: [],
}
];
for(const cell of ids) {
const dataCell = this.getCellRecord(cell);
if (!dataCell) { continue; }
action[2].push(dataCell.id);
action[3].content.push(dataCell.content);
action[3].userRef.push(dataCell.userRef);
action[3].tableRef.push(dataCell.tableRef);
action[3].colRef.push(dataCell.colRef);
action[3].type.push(dataCell.type);
action[3].root.push(dataCell.root);
action[3].rowId.push(dataCell.rowId);
}
return action[2].length > 1 ? action :
action[2].length == 1 ? [...getSingleAction(action)][0] : null;
}
public generateRemovals(ids: number[]) {
const action: BulkRemoveRecord = [
'BulkRemoveRecord',
'_grist_Cells',
ids
];
return action[2].length > 1 ? action :
action[2].length == 1 ? [...getSingleAction(action)][0] : null;
}
public generateUpdate(ids: number[]) {
const action: BulkUpdateRecord = [
'BulkUpdateRecord',
'_grist_Cells',
[],
{
content: [],
userRef: [],
}
];
for(const cell of ids) {
const dataCell = this.getCellRecord(cell);
if (!dataCell) { continue; }
action[2].push(dataCell.id);
action[3].content.push(dataCell.content);
action[3].userRef.push(dataCell.userRef);
}
return action[2].length > 1 ? action :
action[2].length == 1 ? [...getSingleAction(action)][0] : null;
}
/**
* Tests if the user can modify cell's data. Will modify
*/
public async applyAndCheck(
docActions: DocAction[],
userIsOwner: boolean,
haveRules: boolean,
userRef: string,
hasAccess: (cell: SingleCellInfo, state: DocData) => Promise<boolean>
) {
// Owner can modify all comments, without exceptions.
if (userIsOwner) {
return;
}
// First check if we even have actions that modify cell's data.
const cellsActions = docActions.filter(
docAction => getTableId(docAction) === '_grist_Cells' && isDataAction(docAction)
);
// If we don't have any actions, we are good to go.
if (cellsActions.length === 0) { return; }
const fail = () => { throw new ErrorWithCode('ACL_DENY', 'Cannot access cell'); };
// In nutshell we will just test action one by one, and see if user
// can apply it. To do it, we need to keep track of a database state after
// each action (just like regular access is done). Unfortunately, cells' info
// can be partially updated, so we won't be able to determine what cells they
// are attached to. We will assume that bundle has a complete set of information, and
// with this assumption we will skip such actions, and wait for the whole cell to form.
// Create a minimal snapshot of all tables that will be touched by this bundle,
// with all cells info that is needed to check access.
const lastState = this._docData;
// Create a view for current state.
const cellData = this;
// Some cells meta data will be added before rows (for example, when undoing). We will
// postpone checking of such actions until we have a full set of information.
let postponed: Array<number> = [];
// Now one by one apply all actions to the snapshot recording all changes
// to the cell table.
for(const docAction of docActions) {
if (!(getTableId(docAction) === '_grist_Cells' && isDataAction(docAction))) {
lastState.receiveAction(docAction);
continue;
}
// Convert any bulk actions to normal actions
for(const single of getSingleAction(docAction)) {
const id = getRowIdsFromDocAction(single)[0];
if (isAddRecordAction(docAction)) {
// Apply this action, as it might not have full information yet.
lastState.receiveAction(single);
if (haveRules) {
const cell = cellData.getCell(id);
if (cell && cellData.isAttached(cell)) {
// If this is undo, action cell might not yet exist, so we need to check for that.
const record = lastState.getTable(cell.tableId)?.getRecord(cell.rowId);
if (!record) {
postponed.push(id);
} else if (!await hasAccess(cell, lastState)) {
fail();
}
} else {
postponed.push(id);
}
}
} else if (isRemoveRecordAction(docAction)) {
// See if we can remove this cell.
const cell = cellData.getCell(id);
lastState.receiveAction(single);
if (cell) {
// We can remove cell information for any row/column that was removed already.
const record = lastState.getTable(cell.tableId)?.getRecord(cell.rowId);
if (!record || !cell.colId || !(cell.colId in record)) {
continue;
}
if (cell.userRef && cell.userRef !== (userRef || '')) {
fail();
}
}
postponed = postponed.filter((i) => i !== id);
} else {
// We are updating a cell metadata. We will need to check if we can update it.
let cell = cellData.getCell(id);
if (!cell) {
return fail();
}
// We can't update cells, that are not ours.
if (cell.userRef && cell.userRef !== (userRef || '')) {
fail();
}
// And if the cell was attached before, we will need to check if we can access it.
if (cellData.isAttached(cell) && haveRules && !await hasAccess(cell, lastState)) {
fail();
}
// Now receive the action, and test if we can still see the cell (as the info might be moved
// to a different cell).
lastState.receiveAction(single);
cell = cellData.getCell(id)!;
if (cellData.isAttached(cell) && haveRules && !await hasAccess(cell, lastState)) {
fail();
}
}
}
}
// Now test every cell that was added before row (so we added it, but without
// full information, like new rowId or tableId or colId).
for(const id of postponed) {
const cell = cellData.getCell(id);
if (cell && !this.isAttached(cell)) {
return fail();
}
if (haveRules && cell && !await hasAccess(cell, lastState)) {
fail();
}
}
}
}
/**
* Checks if the action is a data action that modifies a _grist_Cells table.
*/
export function isCellDataAction(a: DocAction) {
return getTableId(a) === '_grist_Cells' && isDataAction(a);
}
/**
* Converts a bulk like data action to its non-bulk equivalent. For actions like TableData or ReplaceTableData
* it will return a list of actions, one for each row.
*/
export function* getSingleAction(a: DataAction): Iterable<DataAction> {
if (isAddRecordAction(a) && isBulkAction(a)) {
for(let idx = 0; idx < a[2].length; idx++) {
yield ['AddRecord', a[1], a[2][idx], fromPairs(Object.keys(a[3]).map(key => [key, a[3][key][idx]]))];
}
} else if (isRemoveRecordAction(a) && isBulkAction(a)) {
for(const rowId of a[2]) {
yield ['RemoveRecord', a[1], rowId];
}
} else if (a[0] == 'BulkUpdateRecord') {
for(let idx = 0; idx < a[2].length; idx++) {
yield ['UpdateRecord', a[1], a[2][idx], fromPairs(Object.keys(a[3]).map(key => [key, a[3][key][idx]]))];
}
} else if (a[0] == 'TableData') {
for(let idx = 0; idx < a[2].length; idx++) {
yield ['TableData', a[1], [a[2][idx]],
fromPairs(Object.keys(a[3]).map(key => [key, [a[3][key][idx]]]))];
}
} else if (a[0] == 'ReplaceTableData') {
for(let idx = 0; idx < a[2].length; idx++) {
yield ['ReplaceTableData', a[1], [a[2][idx]], fromPairs(Object.keys(a[3]).map(key => [key, [a[3][key][idx]]]))];
}
} else {
yield a;
}
}