You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/test/server/lib/GranularAccess.ts

4138 lines
184 KiB

import {LocalActionBundle, SandboxActionBundle} from 'app/common/ActionBundle';
import {PermissionDataWithExtraUsers} from 'app/common/ActiveDocAPI';
import {delay} from 'app/common/delay';
import {
AddRecord,
BulkAddRecord,
BulkRemoveRecord,
BulkUpdateRecord,
CellValue,
DocAction,
RemoveRecord,
ReplaceTableData,
TableColValues,
TableDataAction,
UpdateRecord
} from 'app/common/DocActions';
import {OpenDocOptions} from 'app/common/DocListAPI';
import {SHARE_KEY_PREFIX} from 'app/common/gristUrls';
import {isLongerThan, pruneArray} from 'app/common/gutil';
import {UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {GristObjCode} from 'app/plugin/GristData';
import {Deps as DocClientsDeps} from 'app/server/lib/DocClients';
import {DocManager} from 'app/server/lib/DocManager';
import {makeExceptionalDocSession} from 'app/server/lib/DocSession';
import {filterColValues, GranularAccess} from 'app/server/lib/GranularAccess';
import {globalUploadSet} from 'app/server/lib/uploads';
import {assert} from 'chai';
import {cloneDeep, isMatch} from 'lodash';
import * as sinon from 'sinon';
import {TestServer} from 'test/gen-server/apiUtils';
import {createDocTools} from 'test/server/docTools';
import {GristClient, openClient} from 'test/server/gristClient';
import * as testUtils from 'test/server/testUtils';
describe('GranularAccess', function() {
this.timeout(60000);
let home: TestServer;
testUtils.setTmpLogLevel('error');
let owner: UserAPI;
let editor: UserAPI;
let docId: string;
let wsId: number;
let cliOwner: GristClient;
let cliEditor: GristClient;
let docManager: DocManager;
const docTools = createDocTools();
const sandbox = sinon.createSandbox();
async function getWebsocket(api: UserAPI) {
const who = await api.getSessionActive();
return openClient(home.server, who.user.email, who.org?.domain || 'docs');
}
/**
* Add some actions directly into document history, so they can be used as an undo.
*/
async function addFakeBundle(actions: DocAction[],
options?: {
user?: string,
time?: number,
}) {
const doc = await docManager.getActiveDoc(docId);
const history = doc?.getActionHistory();
const actionNum = fakeActionNum;
const actionHash = String(fakeActionNum);
fakeActionNum++;
const bundle: LocalActionBundle = {
actionNum,
actionHash,
parentActionHash: null,
userActions: actions,
undo: actions,
info: [0, {time: Date.now(), ...options} as any],
stored: [],
calc: [],
envelopes: []
};
await history?.recordNextShared(bundle);
return { actionNum, actionHash };
}
let fakeActionNum = 10000;
/**
* Apply actions as a fake undo, inserting them in history and then activating
* them from there.
*/
async function applyAsUndo(client: GristClient, actions: DocAction[],
options?: {
user?: string,
time?: number,
}) {
const {actionNum, actionHash} = await addFakeBundle(actions, options);
const result = await client.send("applyUserActionsById", 0, [actionNum], [actionHash], true);
return result;
}
async function getShareKeyForUrl(linkId: string) {
const shares = await home.dbManager.connection.query(
'select * from shares where link_id = ?', [linkId]);
const key = shares[0].key;
if (!key) {
throw new Error('cannot find share key');
}
return `${SHARE_KEY_PREFIX}${key}`;
}
async function removeShares(sharingDocId: string, api: UserAPI) {
const shares = await owner.getDocAPI(sharingDocId).getRecords('_grist_Shares');
for (const share of shares) {
await api.applyUserActions(docId, [
['RemoveRecord', '_grist_Shares', share.id]
]);
}
}
before(async function() {
home = new TestServer(this);
await home.start(['home', 'docs']);
const api = await home.createHomeApi('chimpy', 'docs', true);
await api.newOrg({name: 'testy', domain: 'testy'});
owner = await home.createHomeApi('chimpy', 'testy', true);
wsId = await owner.newWorkspace({name: 'ws'}, 'current');
await owner.updateWorkspacePermissions(wsId, {
users: {
'kiwi@getgrist.com': 'owners',
'charon@getgrist.com': 'editors',
}
});
editor = await home.createHomeApi('charon', 'testy', true);
docManager = (home.server as any)._docManager;
});
after(async function() {
const api = await home.createHomeApi('chimpy', 'docs');
await api.deleteOrg('testy');
await home.stop();
await globalUploadSet.cleanupAll();
});
afterEach(async function() {
if (docId) {
for (const cli of [cliEditor, cliOwner]) {
await closeClient(cli);
}
docId = "";
}
sandbox.restore();
});
async function getGranularAccess(): Promise<GranularAccess> {
const doc = await docManager.getActiveDoc(docId);
return (doc as any)._granularAccess;
}
async function freshDoc(fixture?: string) {
docId = await owner.newDoc({name: 'doc'}, wsId);
if (fixture) {
await home.copyFixtureDoc(fixture, docId);
await owner.getDocAPI(docId).forceReload();
}
cliEditor = await getWebsocket(editor);
cliOwner = await getWebsocket(owner);
await cliEditor.openDocOnConnect(docId);
await cliOwner.openDocOnConnect(docId);
}
// Reopen clients in a different mode (e.g. default vs fork), or in a different order
// (editor first or owner first).
async function reopenClients(options?: OpenDocOptions & {
first?: 'owner' | 'editor' | 'any',
}) {
cliEditor.flush();
cliOwner.flush();
await cliEditor.send("closeDoc", 0);
await cliOwner.send("closeDoc", 0);
const order = options?.first === 'owner' ? [cliOwner, cliEditor] : [cliEditor, cliOwner];
await order[0].send("openDoc", docId, options);
if (options?.first && options.first !== 'any') {
await delay(250);
}
await order[1].send("openDoc", docId, options);
}
// See the comment in PermissionInfo.ts/evaluateRule() for why we need this.
describe("forces a row check for rules with memo and rec", function() {
it('for -U permission', async function() {
await memoDoc();
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Table1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'user.Access == OWNER', permissionsText: 'all', // Owner can do anything
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'rec.A == 1', permissionsText: '-U'
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'rec.A == 2', permissionsText: '-U', memo: 'Cant2', // Can't update 2
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'rec.A == 3', permissionsText: '-U', memo: 'Cant3', // Can't update 3
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: '', permissionsText: '-U', // Actually can't update anything
}],
]);
// Make sure we see correct memo.
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [1], A: [100]}), []);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [2], A: [100]}), ['Cant2']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [3], A: [100]}), ['Cant3']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], A: [100]}), []);
});
it('for -C permission', async function() {
// Check atomic permission UCD
await memoDoc();
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'all', // Owner can do anything
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 1', permissionsText: '-C' // Can't create rec.A
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 2', permissionsText: '-C', memo: 'Cant2', // Can't create rec with 2
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: '', permissionsText: '-C', // Actually can't createy anything
}],
]);
// Make sure we see correct memo.
await assertDeniedFor(editor.getDocAPI(docId).addRows('Table1', {A: [1]}), []);
await assertDeniedFor(editor.getDocAPI(docId).addRows('Table1', {A: [2]}), ['Cant2']);
await assertDeniedFor(editor.getDocAPI(docId).addRows('Table1', {A: [3]}), []);
});
it('for -D permission', async function() {
// Check atomic permission UCD
await memoDoc();
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'all', // Owner can do anything
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 1', permissionsText: '-D' // Can't remove 1
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 2', permissionsText: '-D', memo: 'Cant2', // Can't remove 2 (with memo)
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: '', permissionsText: '-D', // Actually can't remove anything.
}],
]);
// Make sure we see correct memo.
await assertDeniedFor(editor.getDocAPI(docId).removeRows('Table1', [1]), []);
await assertDeniedFor(editor.getDocAPI(docId).removeRows('Table1', [2]), ['Cant2']);
await assertDeniedFor(editor.getDocAPI(docId).removeRows('Table1', [3]), []);
});
it('for -U with mixed columns', async function() {
// Check atomic permission UCD
await memoDoc();
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: 'A'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'all', // Owner can do anything
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 1', permissionsText: '-U' // Can't update 1
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 2', permissionsText: '-U', memo: 'Cant2', // Can't update 2 (with memo)
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 3', permissionsText: '-U', memo: 'Cant3',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: '', permissionsText: '-U', // Actually can't update this column at all.
}],
]);
// Make sure we see correct memo.
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [1], A: [100]}), []);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [2], A: [100]}), ['Cant2']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [3], A: [100]}), ['Cant3']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], A: [100]}), []);
// But B is ok to update.
await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Table1', {id: [1], B: [100]}));
await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Table1', {id: [2], B: [100]}));
await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Table1', {id: [3], B: [100]}));
await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Table1', {id: [4], B: [100]}));
});
it('for -U with mixed columns with default fallback', async function() {
await memoDoc();
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: 'A'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Table1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'all', // Owner can do anything
}],
//######### A column rules
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 1', permissionsText: '-U'
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 2', permissionsText: '-U', memo: 'Cant2', // Can't update 2 (with memo)
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 3', permissionsText: '-U', memo: 'Cant3',
}],
// ######## Table rules (default)
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'rec.A == 4', permissionsText: '-U', memo: 'Cant4', // Row 4 is read only.
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: '', permissionsText: '-U', memo: 'no', // Actually can't update this table at all.
}]
]);
// Make sure we see correct memo.
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [1], A: [100]}), ['no']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [2], A: [100]}), ['Cant2', 'no']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [3], A: [100]}), ['Cant3', 'no']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], A: [100]}), ['Cant4', 'no']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], B: [100]}), ['Cant4', 'no']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [5], A: [100]}), ['no']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [5], B: [100]}), ['no']);
});
it('for -U with mixed columns with default fallback', async function() {
await memoDoc();
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: 'A'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Table1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'all', // Owner can do anything
}],
//######### A column rules
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 1', permissionsText: '-U'
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 2', permissionsText: '-U' // Can't update 2 (with memo)
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 3', permissionsText: '-U'
}],
// ######## Table rules (default)
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'rec.A == 4', permissionsText: '-U' // Row 4 is read only.
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: '', permissionsText: '-U', memo: 'no', // Actually can't update this table at all.
}]
]);
// Make sure we see correct memo.
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [1], A: [100]}), ['no']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [2], A: [100]}), ['no']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [3], A: [100]}), ['no']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], A: [100]}), ['no']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], B: [100]}), ['no']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [5], A: [100]}), ['no']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [5], B: [100]}), ['no']);
});
it('for -U with mixed columns without default fallback', async function() {
await memoDoc();
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: 'A'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Table1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: 'all', // Owner can do anything
}],
//######### A column rules
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 1', permissionsText: '-U'
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 2', permissionsText: '-U', memo: 'Cant2', // Can't update 2 (with memo)
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == 3', permissionsText: '-U', memo: 'Cant3',
}],
// ######## Table rules (default)
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'rec.A == 4', permissionsText: '-U', memo: 'Cant4', // Row 4 is read only.
}],
]);
// Make sure we see correct memo.
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [1], A: [100]}), []);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [2], A: [100]}), ['Cant2']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [3], A: [100]}), ['Cant3']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], A: [100]}), ['Cant4']);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [4], B: [100]}), ['Cant4']);
await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Table1', {id: [5], A: [100]}));
await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Table1', {id: [5], B: [100]}));
});
async function memoDoc() {
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Table1', [{id: 'A', type: 'Int'}, {id: 'B', type: 'Int'}]],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != OWNER', permissionsText: '-S', // drop schema rights
}],
]);
cliEditor.flush();
cliOwner.flush();
await owner.getDocAPI(docId).addRows('Table1', {A: [1, 2, 3, 4, 5]});
}
});
it('hides transform columns from users without SCHEMA_EDIT when any column has rules', async () => {
// gristHelper_Converted and gristHelper_Transform columns are special. When a document
// has a granular access rules, those columns are hidden from users without SCHEMA_EDIT.
await applyTransformation('B');
// Make sure we don't see transform columns as editor.
assert.deepEqual((await cliEditor.readDocUserAction()), [
['AddRecord', '_grist_Tables_column', 8, {
isFormula: false, type: 'Any', formula: '', colId: '', widgetOptions: '',
label: '', parentPos: 8, parentId: 0
}],
['AddRecord', '_grist_Tables_column', 9, {
isFormula: true, type: 'Any', formula: '', colId: '', widgetOptions: '',
label: '', parentPos: 9, parentId: 0
}],
['ModifyColumn', 'Table1', 'A', {type: 'Text'}],
['UpdateRecord', 'Table1', 1, {A: '1234' }],
['UpdateRecord', '_grist_Tables_column', 2, {widgetOptions: '{}', type: 'Text'}]
]);
});
it('hides transform columns from users without SCHEMA_EDIT if column has rules', async () => {
await applyTransformation('A');
// Make sure we don't see anything as editor (we hid column A).
assert.deepEqual((await cliEditor.readDocUserAction()), [
['AddRecord', '_grist_Tables_column', 8, {
isFormula: false, type: 'Any', formula: '', colId: '', widgetOptions: '',
label: '', parentPos: 8, parentId: 0
}],
['AddRecord', '_grist_Tables_column', 9, {
isFormula: true, type: 'Any', formula: '', colId: '', widgetOptions: '',
label: '', parentPos: 9, parentId: 0
}],
['UpdateRecord', '_grist_Tables_column', 2, {widgetOptions: '', type: 'Any'}]
]);
});
it('respects SCHEMA_EDIT when converting a column', async () => {
// Initially, schema flag defaults to ON for editor.
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Table1', [{id: 'A', type: 'Int'},
{id: 'B', type: 'Int'},
{id: 'C', type: 'Int'}]],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: 'C'}],
// Add at least one access rule. Otherwise the test would succeed
// trivially, via shortcuts in place when the GranularAccess
// hasNuancedAccess test returns false. If there are no access
// rules present, editors can make any edit. Once a granular access
// rule is present, editors lose some rights that are simply too
// hard to compute or we haven't gotten around to.
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access == OWNER', permissionsText: '-R',
}],
['AddRecord', 'Table1', null, {A: 1234, B: 1234}],
]);
// Make a transformation as editor.
await editor.applyUserActions(docId, [
['AddColumn', 'Table1', 'gristHelper_Converted', {type: 'Text', isFormula: false, visibleCol: 0, formula: ''}],
['AddColumn', 'Table1', 'gristHelper_Transform',
{type: 'Text', isFormula: true, visibleCol: 0, formula: 'rec.gristHelper_Converted'}],
["ConvertFromColumn", "Table1", "A", "gristHelper_Converted", "Text", "", 0],
["CopyFromColumn", "Table1", "gristHelper_Transform", "A", "{}"],
]);
// Now turn off schema flag for editor.
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access == EDITOR', permissionsText: '-S',
}],
]);
// Now prepare another transformation.
const transformation = [
['AddColumn', 'Table1', 'gristHelper_Converted2', {type: 'Text', isFormula: false, visibleCol: 0, formula: ''}],
['AddColumn', 'Table1', 'gristHelper_Transform2',
{type: 'Text', isFormula: true, visibleCol: 0, formula: 'rec.gristHelper_Converted2'}],
["ConvertFromColumn", "Table1", "B", "gristHelper_Converted2", "Text", "", 0],
["CopyFromColumn", "Table1", "gristHelper_Transform", "B", "{}"],
];
// Should fail for editor.
await assert.isRejected(editor.applyUserActions(docId, transformation),
/Blocked by full structure access rules/);
// Should go through if run as owner.
await assert.isFulfilled(owner.applyUserActions(docId, transformation));
});
async function applyTransformation(colToHide: string) {
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Table1', [{id: 'A', type: 'Int'}, {id: 'B', type: 'Int'}]],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Table1', colIds: colToHide}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != OWNER', permissionsText: '-S', // drop schema rights
}],
['AddRecord', '_grist_ACLRules', null, {
// Transform columns are only hidden from non-owners when we have a granular access rules.
// Here we will hide either column A (which will be transformed) or column B (which is not relevant
// but will trigger ACL check).
resource: -2, aclFormula: 'user.Access != OWNER', permissionsText: '-R',
}],
['AddRecord', 'Table1', null, {A: 1234}],
]);
cliEditor.flush();
cliOwner.flush();
// Make transformation as owner. This mimics what happens when we apply a transformation using UI (when
// we change column type from Number to Text).
await owner.applyUserActions(docId, [
['AddColumn', 'Table1', 'gristHelper_Converted', {type: 'Text', isFormula: false, visibleCol: 0, formula: ''}],
['AddColumn', 'Table1', 'gristHelper_Transform',
{type: 'Text', isFormula: true, visibleCol: 0, formula: 'rec.gristHelper_Converted'}],
// This action is repeated by the UI just before applying (we don't to repeat it here).
["ConvertFromColumn", "Table1", "A", "gristHelper_Converted", "Text", "", 0],
["CopyFromColumn", "Table1", "gristHelper_Transform", "A", "{}"],
]);
// Make sure we see the actions as owner.
assert.deepEqual(await cliOwner.readDocUserAction(), [
['AddColumn', 'Table1', 'gristHelper_Converted', {isFormula: false, type: 'Text', formula: ''}],
['AddRecord', '_grist_Tables_column', 8, {
isFormula: false,
type: 'Text',
formula: '',
colId: 'gristHelper_Converted',
widgetOptions: '',
label: 'gristHelper_Converted',
parentPos: 8,
parentId: 1
}],
['AddColumn', 'Table1', 'gristHelper_Transform', {
isFormula: true,
type: 'Text',
formula: 'rec.gristHelper_Converted'
}],
['AddRecord', '_grist_Tables_column', 9, {
isFormula: true,
type: 'Text',
formula: 'rec.gristHelper_Converted',
colId: 'gristHelper_Transform',
widgetOptions: '',
label: 'gristHelper_Transform',
parentPos: 9,
parentId: 1
}],
['UpdateRecord', 'Table1', 1, {gristHelper_Converted: '1234'}],
['ModifyColumn', 'Table1', 'A', {type: 'Text'}],
['UpdateRecord', 'Table1', 1, {A: '1234'}],
['UpdateRecord', '_grist_Tables_column', 2, {type: 'Text', widgetOptions: '{}'}],
['UpdateRecord', 'Table1', 1, {gristHelper_Transform: '1234'}]
]);
}
it('persist data when action is rejected', async () => {
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Table1', [{id: 'A'}, {id: 'B'}]],
['AddRecord', 'Table1', null, {B: 1}],
['ModifyColumn', 'Table1', 'B', { isFormula: false, type: 'Text' }],
['ModifyColumn', 'Table1', 'A', { formula: 'UUID() + $B', isFormula: true }],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
// User can't change column B to 2
resource: -1,
aclFormula: 'newRec.B == "2"',
permissionsText: '-U',
memo: 'stop',
}],
]);
// Read A from the engine
const aMemBefore = await memCell('Table1', 'A', 1);
// Read A from database.
const aDbBefore = await dbCell('Table1', 'A', 1);
assert.equal(aMemBefore, aDbBefore);
// Trigger rejection.
await assertDeniedFor(owner.getDocAPI(docId).updateRows('Table1', {id: [1], B: ['2']}), ['stop']);
// Read A value again.
const aDbAfter = await dbCell('Table1', 'A', 1);
// Now read A value from the engine.
const aMemAfter = await memCell('Table1', 'A', 1);
assert.equal(aMemAfter, aDbAfter);
assert.notEqual(aMemAfter, aMemBefore);
});
it('persist data when action is rejected with newRec.A != rec.A formula', async () => {
// Create another example with a different formula.
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Table1', [{id: 'A'}, {id: 'B'}]],
['AddRecord', 'Table1', null, {B: 1}],
['ModifyColumn', 'Table1', 'B', { isFormula: false, type: 'Int' }],
['ModifyColumn', 'Table1', 'A', { formula: 'UUID() if $B else UUID()', isFormula: true }],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
// We can't trigger A change as it will always have a different value.
// It looks like we can't reject this action, as it will cause a fatal failure,
// but this is indirect action, so it will bypass ACL check.
resource: -1,
aclFormula: 'newRec.A != rec.A',
permissionsText: '-U',
memo: 'stop',
}],
]);
const aMemBefore = await memCell('Table1', 'A', 1);
const aDbBefore = await dbCell('Table1', 'A', 1);
await assertDeniedFor(editor.getDocAPI(docId).updateRows('Table1', {id: [1], B: [2]}), ['stop']);
const aMemAfter = await memCell('Table1', 'A', 1);
const aDbAfter = await dbCell('Table1', 'A', 1);
assert.equal(aMemAfter, aDbAfter);
assert.equal(aMemBefore, aDbBefore);
assert.notEqual(aDbBefore, aDbAfter);
assert.notEqual(aMemBefore, aMemAfter);
// Make sure we can update formula, as a value change it's not a direct action.
await assert.isFulfilled(editor.applyUserActions(docId, [
['ModifyColumn', 'Table1', 'A', { formula: 'UUID() + "test"'}],
]));
});
it('persist data when action is rejected with schema action', async () => {
// Reject schema actions
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Table1', [{id: 'A'}]],
['AddRecord', 'Table1', null, {}],
['ModifyColumn', 'Table1', 'A', { formula: 'UUID()', isFormula: true }],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1,
aclFormula: 'user.access != OWNER',
permissionsText: '-S',
memo: 'stop',
}],
]);
const aMemBefore = await memCell('Table1', 'A', 1);
const aDbBefore = await dbCell('Table1', 'A', 1);
await assertDeniedFor(editor.applyUserActions(docId, [
['RemoveColumn', 'Table1', 'A'],
]), ['stop']);
const aMemAfter = await memCell('Table1', 'A', 1);
const aDbAfter = await dbCell('Table1', 'A', 1);
assert.equal(aMemAfter, aDbAfter);
assert.equal(aMemBefore, aDbBefore);
assert.notEqual(aDbBefore, aDbAfter);
assert.notEqual(aMemBefore, aMemAfter);
});
it('fails when action cannot be rejected', async () => {
// Reject schema actions
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Table1', [{id: 'A'}, {id: 'B'}]],
['AddRecord', 'Table1', null, {B: 1}],
['ModifyColumn', 'Table1', 'B', { isFormula: false, type: 'Int' }],
['ModifyColumn', 'Table1', 'A', { formula: 'UUID() if $B else UUID()', isFormula: true }],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
// We can't trigger A change as it will always have a different value.
// We can't also reject this action, as it will cause a fatal failure (with a direct action)
resource: -1,
aclFormula: 'newRec.A != rec.A',
permissionsText: '-U',
memo: 'doom',
}],
]);
const engine = await docManager.getActiveDoc(docId)!;
// Now simulate a situation that extra actions generated by data engine are
// direct, with this, we should receive a fatal error.
const sharing = (engine as any)._sharing;
const stub: any = sinon.stub(sharing, '_createExtraBundle').callsFake((bundle: any, actions: any) => {
const result: SandboxActionBundle = stub.wrappedMethod(bundle, actions);
// Simulate direct actions.
result.direct = result.direct.map(([index]) => [index, true]);
return result;
});
try {
cliEditor.flush();
cliOwner.flush();
await assertFlux(editor.getDocAPI(docId).updateRows('Table1', {id: [1], B: [2]}));
} finally {
stub.restore();
}
assert.equal((await cliEditor.readMessage()).type, 'docShutdown');
assert.equal((await cliOwner.readMessage()).type, 'docShutdown');
});
async function memCell(tableId: string, colId: string, rowId: number) {
const engine = await docManager.getActiveDoc(docId)!;
const systemSession = makeExceptionalDocSession('system');
const {tableData} = await engine.fetchTable(systemSession, tableId, true);
return tableData[3][colId][tableData[2].indexOf(rowId)];
}
async function dbCell(tableId: string, colId: string, rowId: number) {
const engine = await docManager.getActiveDoc(docId)!;
const table = await engine.docStorage.fetchActionData(tableId, [rowId], [colId]);
return table[3][colId][0];
}
it('respects owner-private tables', async function() {
await freshDoc();
// Add spies to check whether unexpected calculations are made, to prevent
// regression of optimizations.
const granularAccess = await getGranularAccess();
const metaSteps = sinon.spy(granularAccess, '_getMetaSteps' as any);
const rowSteps = sinon.spy(granularAccess, '_getSteps' as any);
assert.equal(metaSteps.called, false);
assert.equal(rowSteps.called, false);
// Make a Private table and mark it as owner-only (using temporary representation).
// Make a Public table without any particular access control.
await owner.applyUserActions(docId, [
['AddTable', 'Private', [{id: 'A'}]],
['AddTable', 'PartialPrivate', [{id: 'A'}]],
['AddRecord', 'PartialPrivate', null, { A: 0 }],
['AddRecord', 'PartialPrivate', null, { A: 1 }],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Private', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -3, {tableId: 'PartialPrivate', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
// Negative IDs refer to rowIds used in the same action bundle.
resource: -1,
aclFormula: 'user.Access == "owners"',
permissionsText: 'all',
memo: 'owner check',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: '', permissionsText: 'none',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'user.Access != "owners"', permissionsText: '-S', // drop schema rights
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -3, aclFormula: 'user.Access != "owners" and rec.A > 0', permissionsText: 'none',
}],
['AddTable', 'Public', [{id: 'A'}]],
]);
// Owner can access both Private and Public tables.
await assert.isFulfilled(owner.getDocAPI(docId).getRows('Private'));
await assert.isFulfilled(owner.getDocAPI(docId).getRows('Public'));
// Editor can access the Public table but not the Private table.
await assert.isRejected(editor.getDocAPI(docId).getRows('Private'));
await assert.isFulfilled(editor.getDocAPI(docId).getRows('Public'));
await assertDeniedFor(editor.getDocAPI(docId).getRows('Private'), ['owner check']);
// Metadata to editor should be filtered. Private metadata gets blanked out
// rather than deleted, to keep ids consistent.
const tables = await editor.getDocAPI(docId).getRows('_grist_Tables');
assert.deepEqual(tables['tableId'], ['Table1', '', 'PartialPrivate', 'Public']);
// Owner can download, editor can not.
await assert.isFulfilled((await owner.getWorkerAPI(docId)).downloadDoc(docId));
await assert.isRejected((await editor.getWorkerAPI(docId)).downloadDoc(docId));
// Owner can copy, editor can not.
await assert.isFulfilled((await owner.getWorkerAPI(docId)).copyDoc(docId));
await assert.isRejected((await editor.getWorkerAPI(docId)).copyDoc(docId));
// Owner can use AddColumn, editor can not (even for public table).
await assert.isFulfilled(owner.applyUserActions(docId, [
['AddColumn', 'Public', 'B', {}],
['AddColumn', 'Public', 'C', {}],
]));
await assert.isRejected(editor.applyUserActions(docId, [
['AddColumn', 'Public', 'editorB', {}]
]));
// Owner can use RemoveColumn, editor can not (even for public table).
await assert.isFulfilled(owner.applyUserActions(docId, [
['RemoveColumn', 'Public', 'B']
]));
await assert.isRejected(editor.applyUserActions(docId, [
['RemoveColumn', 'Public', 'C']
]));
// Check that changing a private table's data results in a broadcast to owner but not editor.
cliEditor.flush();
cliOwner.flush();
await owner.getDocAPI(docId).addRows('Private', {A: [99, 100]});
assert.lengthOf(await cliOwner.readDocUserAction(), 1);
assert.equal(cliEditor.count(), 0);
// Check that changing a private table's columns results in a full broadcast to owner, but
// a filtered broadcast to editor.
await assert.isFulfilled(owner.applyUserActions(docId, [
['AddVisibleColumn', 'Private', 'X', {}],
]));
const ownerUpdate = await cliOwner.readDocUserAction();
const editorUpdate = await cliEditor.readDocUserAction();
assert.deepEqual(ownerUpdate.map(a => a[0]), ['AddColumn', 'AddRecord', 'AddRecord', 'AddRecord', 'AddRecord']);
assert.deepEqual(editorUpdate.map(a => a[0]), ['AddRecord', 'AddRecord', 'AddRecord', 'AddRecord']);
assert.equal((ownerUpdate[1] as AddRecord)[3].label, 'X');
assert.equal((editorUpdate[0] as AddRecord)[3].label, '');
// Owner can modify metadata, editor can not.
await assert.isFulfilled(owner.applyUserActions(docId, [
["UpdateRecord", "_grist_Tables_column", 1, {formula: "X"}]
]));
await assert.isRejected(editor.applyUserActions(docId, [
["UpdateRecord", "_grist_Tables_column", 1, {formula: "Y"}]
]));
await assert.isFulfilled(owner.applyUserActions(docId, [
["AddRecord", "_grist_Tables_column", null, {formula: ""}]
]));
await assert.isRejected(editor.applyUserActions(docId, [
["AddRecord", "_grist_Tables_column", null, {formula: ""}]
]));
// Check we have never computed row steps yet.
assert.equal(metaSteps.called, true);
assert.equal(rowSteps.called, false);
// Now do something to tickle row step calculation, and make sure it happens.
await owner.getDocAPI(docId).addRows('PartialPrivate', {A: [99, 100]});
assert.equal(rowSteps.called, true);
// Check editor cannot see private table schema via fetchTableSchema.
assert.match((await cliEditor.send('fetchTableSchema', 0)).error!, /Cannot view code/);
assert.equal((await cliOwner.send('fetchTableSchema', 0)).error, undefined);
});
it('reports memos sensibly', async function() {
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Table1', [{id: 'A'}]],
['AddRecord', 'Table1', null, {A: 'test1'}],
['AddRecord', 'Table1', null, {A: 'test2'}],
['AddTable', 'Table2', [{id: 'A'}]],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Table2', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A == "test1"', permissionsText: 'none',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1,
aclFormula: 'rec.A == "test2"',
permissionsText: '-D',
memo: 'rule_d1',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1,
aclFormula: 'rec.A == "test2"',
permissionsText: '-D',
memo: 'rule_d2',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1,
aclFormula: 'rec.A == "test1"',
permissionsText: '+U',
memo: 'rule_u',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, // Used to have -2, but table-specific rules cannot specify schemaEdit
// permission today; it now gets ignored if they do.
aclFormula: 'True',
permissionsText: '-S',
memo: 'rule_s',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: '', permissionsText: '-U',
}],
]);
await assertDeniedFor(owner.getDocAPI(docId).removeRows('Table1', [1]), []);
await assertDeniedFor(owner.getDocAPI(docId).removeRows('Table1', [2]), ['rule_d1', 'rule_d2']);
await assertDeniedFor(owner.getDocAPI(docId).updateRows('Table1', {id: [2], A: ['x']}),
['rule_u']);
await assertDeniedFor(owner.applyUserActions(docId, [
['AddVisibleColumn', 'Table2', 'B', {}],
]), ['rule_s']);
await assertDeniedFor(owner.applyUserActions(docId, [
['ModifyColumn', 'Table2', 'A', {formula: 'a formula'}],
]), ['rule_s']);
});
it('respects table wildcard', async function() {
await freshDoc();
// Make a Private table, using wildcard.
await owner.applyUserActions(docId, [
['AddTable', 'Private', [{id: 'A'}]],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: 'none',
}],
['AddTable', 'Private', [{id: 'A'}]],
]);
// Owner can access Private table.
await assert.isFulfilled(owner.getDocAPI(docId).getRows('Private'));
// Editor cannot access Private table.
await assert.isRejected(editor.getDocAPI(docId).getRows('Private'));
});
it('checks for special actions after schema actions', async function() {
await freshDoc();
// Make a table with an owner-private column, and with only the owner
// allowed to make schema changes.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A'}, {id: 'B', widgetOptions: "{}"}]],
['AddRecord', 'Data1', null, {A: 'a1', B: 'b1'}],
['AddRecord', 'Data1', null, {A: 'a2', B: 'b2'}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'A'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access not in [OWNER]', permissionsText: '-RU',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'user.Access not in [OWNER]', permissionsText: '-S', // drop schema rights
}],
]);
assert.deepEqual(await owner.getDocAPI(docId).getRows('Data1'), {
id: [ 1, 2 ],
manualSort: [ 1, 2 ],
A: [ 'a1', 'a2' ],
B: [ 'b1', 'b2' ],
});
assert.deepEqual(await editor.getDocAPI(docId).getRows('Data1'), {
id: [ 1, 2 ],
manualSort: [ 1, 2 ],
B: [ 'b1', 'b2' ],
});
await assert.isRejected(editor.applyUserActions(docId, [
['CopyFromColumn', 'Data1', 'A', 'B', {}],
]), /Blocked by full structure access rules/);
await assert.isRejected(editor.applyUserActions(docId, [
['RenameColumn', 'Data1', 'B', 'B'],
['CopyFromColumn', 'Data1', 'A', 'B', {}],
]), /Blocked by full structure access rules/);
assert.deepEqual(await editor.getDocAPI(docId).getRows('Data1'), {
id: [ 1, 2 ],
manualSort: [ 1, 2 ],
B: [ 'b1', 'b2' ],
});
await assert.isFulfilled(owner.applyUserActions(docId, [
['RenameColumn', 'Data1', 'B', 'B'],
['CopyFromColumn', 'Data1', 'A', 'B', {}],
]));
assert.deepEqual(await editor.getDocAPI(docId).getRows('Data1'), {
id: [ 1, 2 ],
manualSort: [ 1, 2 ],
B: [ 'a1', 'a2' ],
});
});
it('respects owner-only structure', async function() {
await freshDoc();
// Make some tables, and lock structure.
await owner.applyUserActions(docId, [
['AddTable', 'Public1', [{id: 'A', type: 'Text'}]],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-S',
}],
['AddTable', 'Public2', [{id: 'A', type: 'Text'}]],
]);
// Owner can access all tables.
await assert.isFulfilled(owner.getDocAPI(docId).getRows('Public1'));
await assert.isFulfilled(owner.getDocAPI(docId).getRows('Public2'));
// Editor can access all tables.
await assert.isFulfilled(editor.getDocAPI(docId).getRows('Public1'));
await assert.isFulfilled(editor.getDocAPI(docId).getRows('Public2'));
// Owner and editor can download.
await assert.isFulfilled((await owner.getWorkerAPI(docId)).downloadDoc(docId));
await assert.isFulfilled((await editor.getWorkerAPI(docId)).downloadDoc(docId));
// Owner and editor can download.
await assert.isFulfilled((await owner.getWorkerAPI(docId)).copyDoc(docId));
await assert.isFulfilled((await editor.getWorkerAPI(docId)).copyDoc(docId));
// Owner can use AddColumn, editor can not.
await assert.isFulfilled(owner.applyUserActions(docId, [
['AddVisibleColumn', 'Public1', 'B', {}],
['AddColumn', 'Public1', 'C', {}],
]));
await assert.isRejected(editor.applyUserActions(docId, [
['AddVisibleColumn', 'Public1', 'editorB', {}]
]));
await assert.isRejected(editor.applyUserActions(docId, [
['AddColumn', 'Public1', 'editorB', {}]
]));
// Owner can use RemoveColumn, editor can not.
await assert.isFulfilled(owner.applyUserActions(docId, [
['RemoveColumn', 'Public1', 'B']
]));
await assert.isRejected(editor.applyUserActions(docId, [
['RemoveColumn', 'Public1', 'C']
]));
// Owner can add an empty table, editor can not.
await assert.isFulfilled(owner.applyUserActions(docId, [
["AddEmptyTable", null]
]));
await assert.isRejected(editor.applyUserActions(docId, [
["AddEmptyTable", null]
]), /Blocked by table structure access rules/);
// Owner can duplicate a table, editor can not.
await assert.isFulfilled(owner.applyUserActions(docId, [
['DuplicateTable', 'Public1', 'Public1Copy', false]
]));
await assert.isRejected(editor.applyUserActions(docId, [
['DuplicateTable', 'Public1', 'Public1Copy', false]
]), /Blocked by table structure access rules/);
// Owner can modify metadata, editor can not.
await assert.isFulfilled(owner.applyUserActions(docId, [
["UpdateRecord", "_grist_Tables_column", 1, {formula: ""}]
]));
await assert.isRejected(editor.applyUserActions(docId, [
["UpdateRecord", "_grist_Tables_column", 1, {formula: "X"}]
// Need to change formula, or update will be ignored and thus succeed
]));
await assert.isFulfilled(owner.applyUserActions(docId, [
["AddRecord", "_grist_Tables_column", null, {formula: ""}]
]));
await assert.isRejected(editor.applyUserActions(docId, [
["AddRecord", "_grist_Tables_column", null, {formula: ""}]
]));
await assert.isFulfilled(owner.applyUserActions(docId, [
["UpdateRecord", "_grist_Pages", 1, {indentation: 2}]
]));
await assert.isRejected(editor.applyUserActions(docId, [
["UpdateRecord", "_grist_Pages", 1, {indentation: 3}]
]));
});
it('owner can edit rules without structure permission', async function() {
await freshDoc();
// Make some tables, and lock structure completely.
await owner.applyUserActions(docId, [
['AddTable', 'Public1', [{id: 'A'}]],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: '', permissionsText: '-S',
}],
['AddTable', 'Public2', [{id: 'A'}]],
]);
// Can still read.
await assert.isFulfilled(owner.getDocAPI(docId).getRows('Public1'));
// Can edit data.
await assert.isFulfilled(owner.getDocAPI(docId).addRows('Public1', {A: [67]}));
// Cannot rename column.
await assert.isRejected(owner.applyUserActions(docId, [
['RenameColumn', 'Public1', 'A', 'Z'],
]), /Blocked by table structure access rules/);
// Can still change rules.
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'True', permissionsText: '+S',
}],
]);
// Can change columns again.
await assert.isFulfilled(owner.applyUserActions(docId, [
['RenameColumn', 'Public1', 'A', 'Z'],
]));
});
it("supports AddEmptyTable", async function() {
await freshDoc();
// Make some tables, and lock structure.
await owner.applyUserActions(docId, [
['AddTable', 'Public1', [{id: 'A'}]],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-S',
}],
['AddTable', 'Public2', [{id: 'A'}]],
]);
await assert.isFulfilled(owner.applyUserActions(docId, [
["AddEmptyTable", null]
]));
await assert.isRejected(editor.applyUserActions(docId, [
["AddEmptyTable", null]
]));
});
it("blocks formulas early", async function() {
await freshDoc();
// Make some tables, and lock structure.
await owner.applyUserActions(docId, [
['AddTable', 'Table1', [{id: 'A'}]],
['AddRecord', 'Table1', null, {A: [100]}],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-S',
}],
]);
// Try a modification that would have a detectable side-effect even if reverted.
await assert.isRejected(editor.applyUserActions(docId, [
["ModifyColumn", "Table1", "A", {"isFormula": true, formula: "datetime.MAXYEAR=1234",
type: 'Int'}]
]), /Blocked by full structure access rules/);
await assert.isRejected(editor.applyUserActions(docId, [
["UpdateRecord", "_grist_Tables_column", 1, {formula: "datetime.MAXYEAR=1234"}]
]), /Blocked by full structure access rules/);
await assert.isRejected(editor.applyUserActions(docId, [
["AddRecord", "_grist_Tables_column", null, {formula: "datetime.MAXYEAR=1234"}]
]), /Blocked by full structure access rules/);
await assert.isRejected(editor.applyUserActions(docId, [
["AddRecord", "_grist_Validations", null, {formula: "datetime.MAXYEAR=1234"}]
]), /Blocked by full structure access rules/);
await assert.isRejected(editor.applyUserActions(docId, [
["SetDisplayFormula", "Table1", null, 1, "datetime.MAXYEAR=1234"]
]), /Blocked by full structure access rules/);
// Make sure that the poison formula was never evaluated.
await owner.applyUserActions(docId, [
["ModifyColumn", "Table1", "A", {"isFormula": true, formula: "datetime.MAXYEAR",
type: 'Int'}]
]);
assert.deepEqual((await owner.getDocAPI(docId).getRows('Table1')).A, [9999]);
});
it("allows AddOrUpdateRecord only with full read access", async function() {
await freshDoc();
// Make some tables, and lock structure.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'}]],
['AddRecord', 'Data1', null, {A: 100}],
['AddTable', 'Data2', [{id: 'A', type: 'Numeric'}]],
['AddRecord', 'Data2', null, {A: 100}],
['AddTable', 'Data3', [{id: 'A', type: 'Numeric'}]],
['AddRecord', 'Data3', null, {A: 100}],
['AddTable', 'Data4', [{id: 'A', type: 'Numeric'}]],
['AddRecord', 'Data4', null, {A: 100}],
['AddTable', 'Data5', [{id: 'A', type: 'Numeric'}]],
['AddRecord', 'Data5', null, {A: 100}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data2', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data3', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -3, {tableId: 'Data4', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -4, {tableId: 'Data5', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-R',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'rec.A == 999', permissionsText: '-R',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -3, aclFormula: 'user.Access != "owners"', permissionsText: '-U',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -4, aclFormula: 'user.Access != "owners"', permissionsText: '-C',
}],
]);
// Can AddOrUpdateRecord on a table with full read access.
await assert.isFulfilled(editor.applyUserActions(docId, [
["AddOrUpdateRecord", "Data1", {"A": 100}, {"A": 200}, {}]
]));
assert.deepEqual(await editor.getDocAPI(docId).getRows('Data1'), {
id: [ 1 ],
manualSort: [ 1 ],
A: [ 200 ],
});
// Cannot AddOrUpdateRecord on a table without read access.
await assert.isRejected(editor.applyUserActions(docId, [
["AddOrUpdateRecord", "Data2", {"A": 100}, {"A": 200}, {}]
]), /Blocked by table read access rules/);
// Cannot AddOrUpdateRecord on a table with partial read access.
await assert.isRejected(editor.applyUserActions(docId, [
["AddOrUpdateRecord", "Data3", {"A": 100}, {"A": 200}, {}]
]), /Blocked by table read access rules/);
// Currently cannot combine AddOrUpdateRecord with RenameTable.
await assert.isRejected(editor.applyUserActions(docId, [
["RenameTable", "Data1", "DataX"],
["RenameTable", "Data2", "Data1"],
["AddOrUpdateRecord", "Data1", {"A": 200}, {"A": 300}, {}]
]), /Can only combine AddOrUpdateRecord and BulkAddOrUpdateRecord with simple data changes/);
// Currently cannot use AddOrUpdateRecord for metadata changes.
await assert.isRejected(editor.applyUserActions(docId, [
["AddOrUpdateRecord", "Data1", {"A": 200}, {"A": 300}, {}],
["AddOrUpdateRecord", "_grist_Tables", {tableId: "Data1"}, {tableId: "DataX"}, {}],
]), /AddOrUpdateRecord cannot yet be used on metadata tables/);
// Currently cannot combine AddOrUpdateRecord with metadata changes.
await assert.isRejected(editor.applyUserActions(docId, [
["AddOrUpdateRecord", "Data1", {"A": 200}, {"A": 300}, {}],
["UpdateRecord", "_grist_Tables", 1, {tableId: "DataX"}],
]), /Can only combine AddOrUpdateRecord and BulkAddOrUpdateRecord with simple data changes/);
// Can combine some simple data changes.
await assert.isFulfilled(editor.applyUserActions(docId, [
["AddOrUpdateRecord", "Data1", {"A": 200}, {"A": 300}, {}],
["AddOrUpdateRecord", "Data1", {"A": 500}, {"A": 600}, {}],
["AddOrUpdateRecord", "Data1", {"A": 300}, {"A": 400}, {}],
]));
assert.deepEqual(await editor.getDocAPI(docId).getRows('Data1'), {
id: [ 1, 2 ],
manualSort: [ 1, 2 ],
A: [ 400, 600 ],
});
// Need both update + create rights
await assert.isRejected(editor.applyUserActions(docId, [
["AddOrUpdateRecord", "Data4", {"A": 100}, {"A": 200}, {}],
]), /Blocked by table update access rules/);
await assert.isRejected(editor.applyUserActions(docId, [
["AddOrUpdateRecord", "Data4", {"A": 300}, {"A": 200}, {}],
]), /Blocked by table update access rules/);
await assert.isRejected(editor.applyUserActions(docId, [
["AddOrUpdateRecord", "Data5", {"A": 100}, {"A": 200}, {}],
]), /Blocked by table create access rules/);
await assert.isRejected(editor.applyUserActions(docId, [
["AddOrUpdateRecord", "Data5", {"A": 300}, {"A": 200}, {}],
]), /Blocked by table create access rules/);
});
it("allows DuplicateTable only with full read access", async function() {
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'}]],
['AddRecord', 'Data1', null, {A: 100}],
['AddTable', 'Data2', [{id: 'A', type: 'Numeric'}]],
['AddRecord', 'Data2', null, {A: 100}],
['AddTable', 'Data3', [{id: 'A', type: 'Numeric'}]],
['AddRecord', 'Data3', null, {A: 100}],
['AddTable', 'Data4', [{id: 'A', type: 'Numeric'}]],
['AddRecord', 'Data4', null, {A: 100}],
['AddTable', 'Data5', [{id: 'A', type: 'Numeric'}]],
['AddRecord', 'Data5', null, {A: 100}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data2', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data3', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -3, {tableId: 'Data4', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -4, {tableId: 'Data5', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-R',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'rec.A == 999', permissionsText: '-R',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -4, aclFormula: 'user.Access != "owners"', permissionsText: '-C',
}],
]);
// Can perform DuplicateTable on a table with full read access.
await assert.isFulfilled(editor.applyUserActions(docId, [
["DuplicateTable", "Data1", "Data1Copy", true]
]));
assert.deepEqual(await editor.getDocAPI(docId).getRows('Data1Copy'), {
id: [ 1 ],
manualSort: [ 1 ],
A: [ 100 ],
});
// Cannot perform DuplicateTable on a table without read access.
for (const includeData of [false, true]) {
await assert.isRejected(editor.applyUserActions(docId, [
["DuplicateTable", "Data2", "Data2Copy", includeData]
]), /Blocked by table read access rules/);
}
// Cannot perform DuplicateTable on a table with partial read access.
for (const includeData of [false, true]) {
await assert.isRejected(editor.applyUserActions(docId, [
["DuplicateTable", "Data3", "Data3Copy", includeData]
]), /Blocked by table read access rules/);
}
// Cannot perform DuplicateTable (with data) on a table without create access.
await assert.isRejected(editor.applyUserActions(docId, [
["DuplicateTable", "Data5", "Data5Copy", true]
]), /Blocked by table create access rules/);
// Check that denied schemaEdit prevents duplication. We can duplicate Data4 table until we deny schemaEdit.
await assert.isFulfilled(editor.applyUserActions(docId, [
["DuplicateTable", "Data1", "Data4Copy0", true]
]));
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-S',
}],
]);
// Cannot perform DuplicateTable on a table without schema edit access.
for (const includeData of [false, true]) {
await assert.isRejected(editor.applyUserActions(docId, [
["DuplicateTable", "Data4", "Data4Copy", includeData]
]), /Blocked by table structure access rules/);
}
// Owner can still perform DuplicateTable, even with partial read access or
// without schema edit access.
for (const includeData of [false, true]) {
await assert.isFulfilled(owner.applyUserActions(docId, [
["DuplicateTable", "Data3", "Data3Copy", includeData]
]));
await assert.isFulfilled(owner.applyUserActions(docId, [
["DuplicateTable", "Data4", "Data4Copy", includeData]
]));
}
// Cannot combine DuplicateTable with other actions.
for (const includeData of [false, true]) {
await assert.isRejected(owner.applyUserActions(docId, [
["UpdateRecord", "_grist_Tables", 4, {tableId: "Data3New"}],
["DuplicateTable", "Data3New", "Data3NewCopy", includeData],
]), /DuplicateTable currently cannot be combined with other actions/);
await assert.isRejected(owner.applyUserActions(docId, [
["AddOrUpdateRecord", "Data3", {"A": 100}, {"A": 200}, {}],
["DuplicateTable", "Data3", "Data3Copy", includeData],
]), /DuplicateTable currently cannot be combined with other actions/);
await assert.isRejected(owner.applyUserActions(docId, [
["DuplicateTable", "Data3", "Data3Copy", includeData],
["AddRecord", "Data3Copy", null, {"A": 100}],
]), /DuplicateTable currently cannot be combined with other actions/);
}
// Cannot duplicate metadata tables.
for (const includeData of [false, true]) {
await assert.isRejected(owner.applyUserActions(docId, [
["DuplicateTable", "_grist_Tables", "_grist_Tables", includeData],
]), /DuplicateTable cannot be used on metadata tables/);
}
});
it('allows a table that only owner can add/remove rows from', async function() {
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Data', [{id: 'A'}]],
['AddRecord', 'Data', null, {A: 42}],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-CD',
}]
]);
// Owner and editor can read table.
assert.lengthOf((await owner.getDocAPI(docId).getRows('Data')).id, 1);
assert.lengthOf((await editor.getDocAPI(docId).getRows('Data')).id, 1);
// Owner and editor can modify rows.
await assert.isFulfilled(owner.getDocAPI(docId).updateRows('Data', {id: [1], A: [67]}));
await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Data', {id: [1], A: [68]}));
// Editor cannot add or remove rows.
await assert.isRejected(editor.getDocAPI(docId).addRows('Data', {A: [999]}));
await assert.isRejected(editor.getDocAPI(docId).removeRows('Data', [1]));
// Owner can add and remove rows.
await assert.isFulfilled(owner.getDocAPI(docId).addRows('Data', {A: [999]}));
await assert.isFulfilled(owner.getDocAPI(docId).removeRows('Data', [1]));
});
it('respects row-level access control', async function() {
await freshDoc();
// Make a table, and limit non-owner access to some rows.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A'},
{id: 'B'},
{id: 'Public', isFormula: true, formula: '$B == "clear"'}]],
['AddRecord', 'Data1', null, {A: 1, B: 'clear'}],
['AddRecord', 'Data1', null, {A: 2, B: 'notclear'}],
['AddRecord', 'Data1', null, {A: 3, B: 'clear'}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners" and not rec.Public', permissionsText: 'none',
}],
// This alternative is equivalent:
// aclFormula: 'user.Access == "owners" or rec.Public', permissionsText: 'all',
// aclFormula: '', permissionsText: 'none',
['AddTable', 'Data2', [{id: 'A'}, {id: 'B'}]],
['AddRecord', 'Data2', null, {A: 1, B: 2}],
]);
assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')).id, [1, 2, 3]);
assert.deepEqual((await editor.getDocAPI(docId).getRows('Data1')).id, [1, 3]);
// Owner can edit all rows, "editor" can only edit public rows.
await assert.isFulfilled(owner.getDocAPI(docId).updateRows(
'Data1', { id: [1], A: [99] }));
await assert.isFulfilled(editor.getDocAPI(docId).updateRows(
'Data1', { id: [1], A: [99] }));
await assert.isRejected(editor.getDocAPI(docId).updateRows(
'Data1', { id: [2], A: [99] }));
// For other tables, editor has normal rights on rows.
await assert.isFulfilled(owner.getDocAPI(docId).updateRows(
'Data2', { id: [1], A: [99] }));
await assert.isFulfilled(editor.getDocAPI(docId).updateRows(
'Data2', { id: [1], A: [99] }));
});
it('respects row-level access control on updates', async function() {
await freshDoc();
// Make a table, and allow update of rows matching a condition.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
{id: 'B', type: 'Numeric'}]],
['AddRecord', 'Data1', null, {A: 1, B: 100}],
['AddRecord', 'Data1', null, {A: 2, B: 200}],
['AddRecord', 'Data1', null, {A: 3, B: 300}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners" and newRec.B <= rec.B', permissionsText: '-U',
}],
]);
await assert.isFulfilled(editor.getDocAPI(docId).updateRows(
'Data1', { id: [1], B: [101] }));
await assert.isRejected(editor.getDocAPI(docId).updateRows(
'Data1', { id: [1], B: [99] }));
await assert.isFulfilled(owner.getDocAPI(docId).updateRows(
'Data1', { id: [1], B: [98] }));
await assert.isFulfilled(editor.getDocAPI(docId).updateRows(
'Data1', { id: [1], B: [99] }));
});
it('handles schema changes within a bundle', async function() {
await freshDoc();
// Owner limits their own row access to a certain table.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
{id: 'B', type: 'Numeric'}]],
['AddRecord', 'Data1', null, {A: 1, B: 100}],
['AddRecord', 'Data1', null, {A: 2, B: 200}],
['AddRecord', 'Data1', null, {A: 3, B: 100}],
['AddTable', 'Data2', [{id: 'A', type: 'Numeric'},
{id: 'B', type: 'Numeric'}]],
['AddRecord', 'Data2', null, {A: 1, B: 100}],
['AddRecord', 'Data2', null, {A: 2, B: 200}],
['AddRecord', 'Data2', null, {A: 3, B: 100}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data2', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: '-U',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'rec.B == 100', permissionsText: '-U',
}],
]);
await assert.isRejected(editor.applyUserActions(docId, [
['UpdateRecord', 'Data2', 3, {A: 99}],
]));
await assert.isFulfilled(editor.applyUserActions(docId, [
['UpdateRecord', 'Data2', 2, {A: 99}],
]));
await assert.isRejected(editor.applyUserActions(docId, [
['UpdateRecord', 'Data1', 2, {A: 99}],
]));
// Swap Data1 and Data2 names, and check all is well.
await assert.isFulfilled(editor.applyUserActions(docId, [
['RenameTable', 'Data1', 'Data3'],
['RenameTable', 'Data2', 'Data1'],
['RenameTable', 'Data3', 'Data2'],
['UpdateRecord', 'Data1', 2, {A: 99}],
]));
await assert.isRejected(editor.applyUserActions(docId, [
['UpdateRecord', 'Data1', 3, {A: 99}],
]));
await assert.isFulfilled(editor.applyUserActions(docId, [
['UpdateRecord', 'Data1', 2, {A: 99}],
]));
await assert.isRejected(editor.applyUserActions(docId, [
['UpdateRecord', 'Data2', 2, {A: 99}],
]));
// This swaps A and B for Data1 (originally Data2).
await assert.isFulfilled(editor.applyUserActions(docId, [
['RenameColumn', 'Data1', 'A', 'C'],
['RenameColumn', 'Data1', 'B', 'A'],
['RenameColumn', 'Data1', 'C', 'B'],
['UpdateRecord', 'Data1', 2, {B: 99}],
]));
await assert.isRejected(editor.applyUserActions(docId, [
['UpdateRecord', 'Data1', 3, {B: 99}],
]));
await assert.isFulfilled(editor.applyUserActions(docId, [
['UpdateRecord', 'Data1', 2, {B: 99}],
]));
await assert.isRejected(editor.applyUserActions(docId, [
['RenameColumn', 'Data1', 'A', 'C'],
['RenameColumn', 'Data1', 'B', 'A'],
['RenameColumn', 'Data1', 'C', 'B'],
['UpdateRecord', 'Data1', 3, {A: 99}],
]));
});
it('only owners can change rules', async function() {
// We currently have hardcoded permission that only owners can edit rules.
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
{id: 'B', type: 'Numeric'}]],
['AddTable', 'Sensitive', [{id: 'A', type: 'Numeric'},
{id: 'B', type: 'Numeric'}]],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Sensitive', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'newRec.A != 1', permissionsText: '-U',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'user.Access != "owners"', permissionsText: '-R',
}],
]);
cliEditor.flush();
cliOwner.flush();
await assert.isFulfilled(owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLRules', null, {
resource: 1, aclFormula: 'newRec.A != 1', permissionsText: '-U',
}],
]));
assert.equal((await cliEditor.readMessage()).type, 'docShutdown');
assert.equal((await cliOwner.readMessage()).type, 'docShutdown');
await assert.isRejected(editor.applyUserActions(docId, [
['AddRecord', '_grist_ACLRules', null, {
resource: 1, aclFormula: 'newRec.A != 1', permissionsText: '-U',
}],
]), /Only owners can modify access rules/);
await assert.isFulfilled(owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLRules', null, {
resource: 1, aclFormula: 'user.Access != "owners"', permissionsText: '-R',
}]
]));
cliEditor.flush();
cliOwner.flush();
await assert.isFulfilled(owner.applyUserActions(docId, [
['RenameTable', 'Data1', 'Data2'],
]));
assert.deepEqual(await cliOwner.readDocUserAction(), [
[ 'RenameTable', 'Data1', 'Data2' ],
[ 'UpdateRecord', '_grist_Tables', 2, { tableId: 'Data2' } ],
[ 'UpdateRecord', '_grist_ACLResources', 2, { tableId: 'Data2' } ]
]);
assert.deepEqual(await cliEditor.readDocUserAction(), [
[ 'RenameTable', 'Data1', 'Data2' ],
[ 'UpdateRecord', '_grist_Tables', 2, { tableId: 'Data2' } ]
]);
// Editor cannot download doc with some private info.
await assert.isRejected((await editor.getWorkerAPI(docId)).downloadDoc(docId));
// Grant editor special access to access rules.
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: '*SPECIAL', colIds: 'AccessRules'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access == "editors"', permissionsText: '+R',
}],
]);
cliEditor.flush();
cliOwner.flush();
await assert.isFulfilled(owner.applyUserActions(docId, [
['RenameTable', 'Data2', 'Data3'],
]));
for (const cli of [cliEditor, cliOwner]) {
assert.deepEqual(await cli.readDocUserAction(), [
[ 'RenameTable', 'Data2', 'Data3' ],
[ 'UpdateRecord', '_grist_Tables', 2, { tableId: 'Data3' } ],
[ 'UpdateRecord', '_grist_ACLResources', 2, { tableId: 'Data3' } ]
]);
}
// Editor still cannot download doc.
await assert.isRejected((await editor.getWorkerAPI(docId)).downloadDoc(docId));
// Grant editor special access to download document.
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: '*SPECIAL', colIds: 'FullCopies'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access == "editors"', permissionsText: '+R',
}],
]);
// Download should work, and have FullCopies rules/resources removed.
const download = await (await editor.getWorkerAPI(docId)).downloadDoc(docId);
const worker = await editor.getWorkerAPI('import');
const uploadId = await worker.upload(await (download as any).buffer(), 'upload.grist');
const workspaceId = (await editor.getOrgWorkspaces('current'))[0].id;
const copyDocId = (await worker.importDocToWorkspace(uploadId, workspaceId)).id;
assert.deepEqual(await editor.getDocAPI(copyDocId).getRows('_grist_ACLResources'),
{ id: [ 1, 2, 3, 4 ],
colIds: [ '', '*', '*', 'AccessRules' ],
tableId: [ '', 'Data3', 'Sensitive', '*SPECIAL' ] });
assert.deepEqual((await editor.getDocAPI(copyDocId).getRows('_grist_ACLRules')).resource,
[ 1, 2, 3, 1, 1, 4 ]);
// Similarly for a fork.
cliEditor.flush();
const forkDocId = (await cliEditor.send("fork", 0)).data.docId as string;
assert.deepEqual(await editor.getDocAPI(forkDocId).getRows('_grist_ACLResources'),
{ id: [ 1, 2, 3, 4 ],
colIds: [ '', '*', '*', 'AccessRules' ],
tableId: [ '', 'Data3', 'Sensitive', '*SPECIAL' ] });
assert.deepEqual((await editor.getDocAPI(copyDocId).getRows('_grist_ACLRules')).resource,
[ 1, 2, 3, 1, 1, 4 ]);
// Original doc should be unchanged.
assert.deepEqual(await editor.getDocAPI(docId).getRows('_grist_ACLResources'),
{ id: [ 1, 2, 3, 4, 5 ],
colIds: [ '', '*', '*', 'AccessRules', 'FullCopies' ],
tableId: [ '', 'Data3', 'Sensitive', '*SPECIAL', '*SPECIAL' ] });
assert.deepEqual((await editor.getDocAPI(docId).getRows('_grist_ACLRules')).resource,
[ 1, 2, 3, 1, 1, 4, 5 ]);
});
it('handles fork ownership gracefully', async function() {
// Make a document with some data only owners have access to.
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'}]],
['AddRecord', 'Data1', 1, {A: 14}],
['AddRecord', 'Data1', 2, {A: 15}],
['AddTable', 'Sensitive', [{id: 'A', type: 'Numeric'}]],
['AddRecord', 'Sensitive', 1, {A: 16}],
['AddRecord', 'Sensitive', 2, {A: 17}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Sensitive', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: 'none',
}],
]);
// Check editor can write to public table in regular document mode.
assert.equal((await cliEditor.send('applyUserActions', 0,
[['AddRecord', 'Data1', null, {A: 99}]])).error,
undefined);
// Check editor cannot read sensitive data.
assert.match((await cliEditor.send('fetchTable', 0, 'Sensitive')).error!,
/Blocked by table read access rules/);
// Check that in fork mode, editor still cannot read sensitive data.
await reopenClients({openMode: 'fork'});
assert.match((await cliEditor.send('fetchTable', 0, 'Sensitive')).error!,
/Blocked by table read access rules/);
// Nor can editor write in (pre)-fork mode. Need to send an explicit "fork" command
// to create a different doc to write to (tested elsewhere).
assert.match((await cliEditor.send('applyUserActions', 0,
[['AddRecord', 'Data1', null, {A: 99}]])).error!,
/No write access/);
// Grant editor special access to copy/download/fork document.
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: '*SPECIAL', colIds: 'FullCopies'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access == "editors"', permissionsText: '+R',
}],
]);
// Check editor can still write to public table in regular document mode.
await reopenClients();
assert.equal((await cliEditor.send('applyUserActions', 0,
[['AddRecord', 'Data1', null, {A: 99}]])).error,
undefined);
// Editor still cannot read sensitive data in regular mode (although they could download
// it, tested elsewhere).
assert.match((await cliEditor.send('fetchTable', 0, 'Sensitive')).error!,
/Blocked by table read access rules/);
// But now, if opening in fork mode, editor reads as owner, as if they' already
// copied everything and become its owner.
await reopenClients({openMode: 'fork'});
assert.deepEqual((await cliEditor.send('fetchTable', 0, 'Sensitive')).data.tableData[3],
{ manualSort: [ 1, 2 ], A: [ 16, 17 ] });
// Modifications remain forbidden. Were we to send the 'fork' message,
// (tested elsewhere) we'd get back a new docId to switch to, and there
// the editor would be a true owner.
assert.match((await cliEditor.send('applyUserActions', 0,
[['AddRecord', 'Data1', null, {A: 99}]])).error!,
/No write access/);
});
it('handles outgoing actions when an action triggers changes in other tables', async function() {
await freshDoc();
// Set up a situation where there are two linked tables (a change to Contacts will trigger a
// change to Interactions), and one table has partial access.
await owner.applyUserActions(docId, [
['AddTable', 'Contacts', [{id: 'Name', type: 'Text'}, {id: 'Show', type: 'Bool'}]],
['AddTable', 'Interactions', [
{id: 'Contact', type: 'Ref:Contacts'},
{id: 'ContactName', formula: '$Contact.Name'},
]],
['AddRecord', 'Contacts', -1, {Name: 'Bob', Show: true}],
['AddRecord', 'Contacts', -2, {Name: 'Jane', Show: false}],
['AddRecord', 'Interactions', -1, {Contact: -1}],
['AddRecord', 'Interactions', -2, {Contact: -1}],
['AddRecord', 'Interactions', -3, {Contact: -2}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Contacts', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners" and not rec.Show', permissionsText: 'none',
}],
]);
// Connect an editor, so that there is someone to receive filtered outgoing actions.
cliEditor.flush();
// Make a change that triggers an update to two different tables. It should succeed.
await assert.isFulfilled(editor.getDocAPI(docId).updateRows('Contacts', {id: [1], Name: ['Bert']}));
// Read the broadcast action, and check that it includes both expected updates.
const docAction1 = await cliEditor.readDocUserAction();
assert.deepEqual(docAction1, [
['UpdateRecord', 'Contacts', 1, { Name: 'Bert' }],
['BulkUpdateRecord', 'Interactions', [ 1, 2 ], { ContactName: ['Bert', 'Bert'] }],
]);
// As a secondary test, check that the edit restriction works.
await assert.isRejected(editor.getDocAPI(docId).updateRows('Contacts', {id: [2], Name: ['Jennifer']}),
/Blocked by row update access rules/);
// Check that it didn't trigger a broadcast.
assert.equal(await isLongerThan(cliEditor.readDocUserAction(), 500), true);
});
it('restricts helper columns of restricted user columns', async function() {
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Contacts', [{id: 'Name', type: 'Text'}]],
['AddTable', 'Interactions', [
{id: 'Contact', type: 'Ref:Contacts'},
{id: 'Show', type: 'Bool'},
]],
['AddRecord', 'Contacts', 1, {Name: 'Bob'}],
['AddRecord', 'Contacts', 2, {Name: 'Jane'}],
['AddRecord', 'Interactions', 3, {Contact: 1, Show: true}],
['AddRecord', 'Interactions', 4, {Contact: 2, Show: false}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Interactions', colIds: 'Contact'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners" and not rec.Show', permissionsText: 'none',
}],
]);
cliOwner.flush();
cliEditor.flush();
await owner.applyUserActions(docId, [
// Give Interactions.Contact a display column...
['SetDisplayFormula', 'Interactions', null, 8, '$Contact.Name'],
// ...and a conditional formatting rule column
['AddEmptyRule', 'Interactions', 0, 8],
['UpdateRecord', '_grist_Tables_column', 11, {'formula': '$Contact.Name == "Bob"'}],
// Repeat the same for a *field* that uses that column
['SetDisplayFormula', 'Interactions', 13, null, '$Contact.Name + "2"'],
['AddEmptyRule', 'Interactions', 13, 0],
['UpdateRecord', '_grist_Tables_column', 13, {'formula': '$Contact.Name == "Jane"'}],
]);
assert.deepEqual(
(await cliOwner.readDocUserAction()).slice(-4),
[
["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_ConditionalRule": [true, false]}],
["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_ConditionalRule2": [false, true]}],
["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_Display": ["Bob", "Jane"]}],
["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_Display2": ["Bob2", "Jane2"]}],
],
);
// The helper columns are censored for the editor.
// They shouldn't actually be 100% censored in outgoing actions,
// this is a limitation with formulas involving `rec`.
// When fetching records as below, they're correctly partially censored.
const censoreds: CellValue[] = [[GristObjCode.Censored], [GristObjCode.Censored]];
assert.deepEqual(
(await cliEditor.readDocUserAction()).slice(-4),
[
["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_ConditionalRule": censoreds}],
["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_ConditionalRule2": censoreds}],
["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_Display": censoreds}],
["BulkUpdateRecord", "Interactions", [3, 4], {"gristHelper_Display2": censoreds}],
],
);
// Check that the columns were added correctly
const columns = await owner.getDocAPI(docId).getRecords("_grist_Tables_column");
assert.isTrue(
isMatch(columns, [
// Table1
{id: 1}, {id: 2}, {id: 3}, {id: 4},
// Contacts
{id: 5, fields: {parentId: 2, colId: 'manualSort'}},
{id: 6, fields: {parentId: 2, colId: 'Name', type: 'Text'}},
// Interactions
{id: 7, fields: {parentId: 3, colId: 'manualSort'}},
{id: 8, fields: {parentId: 3, colId: 'Contact', type: 'Ref:Contacts', displayCol: 10, rules: ['L', 11]}},
{id: 9, fields: {parentId: 3, colId: 'Show', type: 'Bool'}},
{id: 10, fields: {parentId: 3, colId: 'gristHelper_Display', type: 'Any', formula: '$Contact.Name'}},
{
id: 11, fields: {
parentId: 3, colId: 'gristHelper_ConditionalRule', type: 'Any', formula: '$Contact.Name == "Bob"'
}
},
{id: 12, fields: {parentId: 3, colId: 'gristHelper_Display2', type: 'Any', formula: '$Contact.Name + "2"'}},
{
id: 13, fields: {
parentId: 3, colId: 'gristHelper_ConditionalRule2', type: 'Any', formula: '$Contact.Name == "Jane"'
}
},
]),
"Unexpected columns: " + JSON.stringify(columns, null, 4),
);
// Check that the field is also correct
const fields = await owner.getDocAPI(docId).getRecords("_grist_Views_section_field");
assert.isTrue(
isMatch(fields[12], {id: 13, fields: {colRef: 8, displayCol: 12, rules: ['L', 13]}}),
"Unexpected fields: " + JSON.stringify(fields, null, 4),
);
const commonColumns = {
id: [3, 4],
manualSort: [1, 2],
Show: [true, false],
};
const ownerRows = await owner.getDocAPI(docId).getRows("Interactions");
assert.deepEqual(ownerRows, {
...commonColumns,
Contact: [1, 2],
gristHelper_Display: ['Bob', 'Jane'],
gristHelper_Display2: ['Bob2', 'Jane2'],
gristHelper_ConditionalRule: [true, false],
gristHelper_ConditionalRule2: [false, true],
});
const editorRows = await editor.getDocAPI(docId).getRows("Interactions");
assert.deepEqual(editorRows, {
...commonColumns,
Contact: [1, [GristObjCode.Censored]],
// Helper columns are censored in tandem with the associated user column
gristHelper_Display: ['Bob', [GristObjCode.Censored]],
gristHelper_Display2: ['Bob2', [GristObjCode.Censored]],
gristHelper_ConditionalRule: [true, [GristObjCode.Censored]],
gristHelper_ConditionalRule2: [false, [GristObjCode.Censored]],
});
});
it('respects row-level access control on creates (without formulas)', async function() {
await freshDoc();
// Make a table, and allow creation of rows only matching a condition.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
{id: 'B', type: 'Numeric'}]],
['AddRecord', 'Data1', null, {A: 100, B: 50}],
['AddRecord', 'Data1', null, {A: 200, B: 150}],
['AddRecord', 'Data1', null, {A: 300, B: 250}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners" and newRec.A <= newRec.B', permissionsText: '-C',
}],
]);
assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 3);
await assert.isFulfilled(editor.getDocAPI(docId).addRows(
'Data1', { A: [10], B: [1] }));
assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 4);
await assert.isRejected(editor.getDocAPI(docId).addRows(
'Data1', { A: [1], B: [10] }));
assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 4);
});
it('respects row-level access control on creates (with formulas)', async function() {
await freshDoc();
// Make a table, and allow creation of rows only matching a condition.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
{id: 'B', type: 'Numeric'},
{id: 'Good', isFormula: true, formula: '$A > $B'}]],
['AddRecord', 'Data1', null, {A: 100, B: 50}],
['AddRecord', 'Data1', null, {A: 200, B: 150}],
['AddRecord', 'Data1', null, {A: 300, B: 250}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners" and not newRec.Good', permissionsText: '-C',
}],
]);
assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 3);
await assert.isFulfilled(editor.getDocAPI(docId).addRows(
'Data1', { A: [10], B: [1] }));
assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 4);
await assert.isRejected(editor.getDocAPI(docId).addRows(
'Data1', { A: [1], B: [10] }));
assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 4);
});
it('respects row-level access control on deletes', async function() {
await freshDoc();
// Make a table, and allow creation of rows only matching a condition.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
{id: 'B', type: 'Numeric'},
{id: 'Good', isFormula: true, formula: '$A > $B'}]],
['AddRecord', 'Data1', null, {A: 100, B: 50}],
['AddRecord', 'Data1', null, {A: 200, B: 250}],
['AddRecord', 'Data1', null, {A: 300, B: 250}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners" and not rec.Good', permissionsText: '-D',
}],
]);
assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 3);
await assert.isFulfilled(editor.getDocAPI(docId).removeRows(
'Data1', [1]));
assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 2);
await assert.isRejected(editor.getDocAPI(docId).removeRows(
'Data1', [2]));
assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 2);
});
it('can prevent duplicates', async function() {
await freshDoc();
// Make a table, and allow creation or update of rows with unique keys.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
{id: 'Count', isFormula: true, formula: 'len(Data1.lookupRecords(A=$A))'}]],
['AddRecord', 'Data1', null, {A: 100}],
['AddRecord', 'Data1', null, {A: 200}],
['AddRecord', 'Data1', null, {A: 300}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1,
aclFormula: 'newRec.Count > 1',
permissionsText: '-CU',
memo: 'duplicate check',
}],
]);
const noop = assertUnchanged(() => owner.getDocAPI(docId).getRows('Data1'));
// Adding a row with a distinct key should work.
assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 3);
await assert.isFulfilled(owner.getDocAPI(docId).addRows('Data1', { A: [400] }));
assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 4);
// Adding a row with a duplicated key should fail.
await noop(assertDeniedFor(owner.getDocAPI(docId).addRows( 'Data1', { A: [200] }),
['duplicate check']));
assert.equal((await owner.getDocAPI(docId).getRows('Data1')).id.length, 4);
// If original is removed, adding the row should now succeed.
await assert.isFulfilled(owner.getDocAPI(docId).removeRows('Data1', [2]));
await assert.isFulfilled(owner.getDocAPI(docId).addRows('Data1', { A: [200] }));
// Updating a row to duplicate an existing key should fail.
await noop(assert.isRejected(owner.getDocAPI(docId).updateRows('Data1',
{ id: [1], A: [200] })));
// Updating a row to have a new key should succeed.
await assert.isFulfilled(owner.getDocAPI(docId).updateRows('Data1',
{ id: [1], A: [500] }));
// Adding rows containing a new duplicate should fail.
await noop(assert.isRejected(owner.getDocAPI(docId).addRows('Data1', { A: [600, 600] })));
// A duplicate introduced within an action bundle should cause the bundle to be rejected.
await noop(assert.isRejected(owner.applyUserActions(docId, [
['AddRecord', 'Data1', null, {A: 700}],
['UpdateRecord', 'Data1', 1, {A: 700}],
])));
// An action bundle should otherwise succeed.
await assert.isFulfilled(owner.applyUserActions(docId, [
['AddRecord', 'Data1', null, {A: 800}],
['UpdateRecord', 'Data1', 1, {A: 700}],
]));
// Adding 700 at this point should be rejected as a duplicate.
await noop(assert.isRejected(owner.applyUserActions(docId, [
['AddRecord', 'Data1', -1, {A: 700}],
])));
// Adding 700 and immediately overwriting should be accepted.
await assert.isFulfilled(owner.applyUserActions(docId, [
['AddRecord', 'Data1', -1, {A: 700}],
['UpdateRecord', 'Data1', -1, {A: 750}],
]));
// Again, a duplicate introduced in a bundle should be rejected.
await noop(assert.isRejected(owner.applyUserActions(docId, [
['AddRecord', 'Data1', -1, {A: 760}],
['UpdateRecord', 'Data1', -1, {A: 750}],
])));
});
it('permits indirect changes via formulas', async function() {
await freshDoc();
// Make a table with a data column A, and a formula column Count.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
{id: 'Count', isFormula: true, formula: 'len(Data1.lookupRecords(A=$A))'}]],
['AddRecord', 'Data1', null, {A: 100}],
['AddRecord', 'Data1', null, {A: 200}],
['AddRecord', 'Data1', null, {A: 300}],
// Forbid write access to Count (this is redundant since the data engine forbids
// writing to a formula column in any case).
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'Count'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'True', permissionsText: '-U',
}],
]);
// Check initial state of formula column.
assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')).Count, [1, 1, 1]);
// Make a change in data column.
await assert.isFulfilled(owner.getDocAPI(docId).updateRows('Data1',
{ id: [1], A: [200] }));
// Check that formula column changed as expected.
assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')).Count, [2, 2, 1]);
// Check that we cannot write to the formula column.
await assert.isRejected(owner.getDocAPI(docId).updateRows('Data1',
{ id: [1], Count: [200] }),
/Can't save value to formula column/);
});
it('permits indirect changes via type conversion', async function() {
await freshDoc();
// Make a table with a data column A, and make it read-only.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Int'}]],
['AddRecord', 'Data1', null, {A: 100}],
['AddRecord', 'Data1', null, {A: 200}],
['AddRecord', 'Data1', null, {A: 300}],
// Forbid write access to column.
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'A'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1,
aclFormula: 'True',
permissionsText: '-CUD',
memo: 'COMPUTER SAYS NO'
}],
]);
// Check initial state of column.
assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')).A, [100, 200, 300]);
// Try to make a change in data column.
await assertDeniedFor(owner.getDocAPI(docId).updateRows('Data1',
{ id: [1], A: [200] }),
['COMPUTER SAYS NO']);
// Convert column in bulk - we have +S bit so we can do this.
await owner.applyUserActions(docId, [
["ModifyColumn", "Data1", "A", {"type": "Text"}]
]);
// Check that column changed as expected.
assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')).A, ['100', '200', '300']);
});
it('permits indirect changes via simple summary tables', async function() {
await freshDoc();
// Make test tables.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'G', type: 'Numeric'}, {id: 'V', type: 'Numeric'}]],
['AddRecord', 'Data1', null, {G: 1, V: 10}],
['AddRecord', 'Data1', null, {G: 2, V: 20}],
['AddRecord', 'Data1', null, {G: 2, V: 20}],
['AddTable', 'Data2', [{id: 'A', type: 'Numeric'}]],
]);
// Get tableRef and colRef of column 'G' so we can make a summary table.
const tableRef = (await owner.getDocAPI(docId).getRows('_grist_Tables',
{filters: { tableId: ['Data1'] }})).id[0];
const colRef = (await owner.getDocAPI(docId).getRows('_grist_Tables_column',
{filters: { colId: ['G'] }})).id[0];
// Make a summary table.
await owner.applyUserActions(docId, [
['CreateViewSection', tableRef, 0, 'detail', [colRef], null]
]);
// Allow non-owners to edit data table only, not summary table.
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != OWNER', permissionsText: '-CUD',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'user.Access != OWNER', permissionsText: '+CUD',
}],
]);
// Check summary looks as expected.
assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1_summary_G')).V, [10, 40]);
// Make sure that editor can indirectly create a new row in summary, despite access rules.
await editor.applyUserActions(docId, [
['AddRecord', 'Data1', null, {G: 3, V: 5}],
]);
assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1_summary_G')).V, [10, 40, 5]);
// Make sure that editor can indirectly hide a row in summary.
await editor.applyUserActions(docId, [
['UpdateRecord', 'Data1', 1, {G: 3}],
]);
assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1_summary_G')).V, [40, 15]);
// Make sure editor cannot directly change Data2.
await assert.isRejected(editor.applyUserActions(docId, [
['AddRecord', 'Data2', null, {A: 1}],
]), /Blocked by table create access rules/);
});
it('permits indirect changes via flattened summary tables', async function() {
await freshDoc();
// Make test tables.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'G', type: 'ChoiceList'}, {id: 'V', type: 'Numeric'}]],
['AddRecord', 'Data1', null, {G: ['L', 1, 2], V: 10}],
['AddRecord', 'Data1', null, {G: ['L', 2], V: 20}],
['AddRecord', 'Data1', null, {G: ['L', 2], V: 20}],
['AddTable', 'Data2', [{id: 'A', type: 'Numeric'}]],
]);
// Get tableRef and colRef of column 'G' so we can make a summary table.
const tableRef = (await owner.getDocAPI(docId).getRows('_grist_Tables',
{filters: { tableId: ['Data1'] }})).id[0];
const colRef = (await owner.getDocAPI(docId).getRows('_grist_Tables_column',
{filters: { colId: ['G'] }})).id[0];
// Make a summary table.
await owner.applyUserActions(docId, [
['CreateViewSection', tableRef, 0, 'detail', [colRef], null]
]);
// Block create/update/delete to non-owners on summary table.
// Allow non-owners to edit data table only, not summary table.
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != OWNER', permissionsText: '-CUD',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'user.Access != OWNER', permissionsText: '+CUD',
}],
]);
// Check summary looks as expected.
assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1_summary_G')).V, [10, 50]);
// Make sure that editor can indirectly create a new row in summary, despite access rules.
await editor.applyUserActions(docId, [
['AddRecord', 'Data1', null, {G: ['L', 2, 3, 4], V: 5}],
]);
assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1_summary_G')).V, [10, 55, 5, 5]);
// Make sure that editor can indirectly hide a row in summary.
await editor.applyUserActions(docId, [
['UpdateRecord', 'Data1', 1, {G: ['L', 3]}],
]);
assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1_summary_G')).V, [45, 15, 5]);
// Make sure editor cannot directly change Data2.
await assert.isRejected(editor.applyUserActions(docId, [
['AddRecord', 'Data2', null, {A: 1}],
]), /Blocked by table create access rules/);
});
it('uncensors the raw view section of a source table when a summary table is visible', async function() {
await freshDoc();
const docApi = owner.getDocAPI(docId);
// The doc starts out with one table by default, with three view sections (widgets): one 'normal',
// one raw, and one record card.
// Initially, they have no titles. Give them some. Note that naming the raw section 'My Data'
// also renames the table itself to 'My_Data'.
await docApi.updateRows('_grist_Views_section', {id: [1, 2], title: ['Widget', 'My Data']});
// Check the initial tableId and title values.
let tableIds = (await docApi.getRows('_grist_Tables')).tableId;
let sectionTitles = (await docApi.getRows('_grist_Views_section')).title;
assert.deepEqual(tableIds, ['My_Data']);
assert.deepEqual(sectionTitles, ['Widget', 'My Data', '']);
// Deny all access to the table.
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: 'My_Data', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: null, permissionsText: '-CRUD',
}],
]);
// Now all those values are 'censored', i.e. blank.
tableIds = (await docApi.getRows('_grist_Tables')).tableId;
sectionTitles = (await docApi.getRows('_grist_Views_section')).title;
assert.deepEqual(tableIds, ['']);
assert.deepEqual(sectionTitles, ['', '', '']);
// Make a summary table on the table grouped by column 'A'.
await owner.applyUserActions(docId, [
['CreateViewSection', 1, 0, 'detail', [2], null]
]);
// Get the values again.
tableIds = (await docApi.getRows('_grist_Tables')).tableId;
sectionTitles = (await docApi.getRows('_grist_Views_section')).title;
// The source tableId is still hidden, and we now have a new summary table.
assert.deepEqual(tableIds, ['', 'My_Data_summary_A']);
assert.deepEqual(sectionTitles, [
// Source table sections. The normal section is still hidden, but the raw section title is revealed.
'', 'My Data', '',
// Summary table sections. These aren't hidden, they just have no titles.
'', '',
]);
});
it('merges rec and newRec for creations and deletions', async function() {
await freshDoc();
// Make a table with a data column A, and allow user to add/remove odd rows.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Int'}]],
['AddRecord', 'Data1', null, {A: 100}],
['AddRecord', 'Data1', null, {A: 201}],
['AddRecord', 'Data1', null, {A: 301}],
// Forbid write access to column.
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.A % 2 == 0', permissionsText: '-CD', memo: 'STOP1',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'newRec.A % 2 == 0', permissionsText: '-CD', memo: 'STOP2',
}],
]);
// Cannot add a row with even A.
await assertDeniedFor(owner.getDocAPI(docId).addRows('Data1', {A: [500]}),
['STOP1', 'STOP2']);
// Cannot remove a row with even A.
await assertDeniedFor(owner.getDocAPI(docId).removeRows('Data1', [1]),
['STOP1', 'STOP2']);
// Can add a row with odd A.
await assert.isFulfilled(owner.getDocAPI(docId).addRows('Data1', {A: [501]}));
// Can remove a row with odd A.
await assert.isFulfilled(owner.getDocAPI(docId).removeRows('Data1', [2]));
});
it('newRec behavior in a long or mixed bundle is as expected', async function() {
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
{id: 'B', isFormula: true, formula: '$A + 1'},
{id: 'C'}]],
['AddRecord', 'Data1', null, {A: 101}],
['AddRecord', 'Data1', null, {A: 201}],
['AddRecord', 'Data1', null, {A: 301}],
['AddRecord', 'Data1', null, {A: 401}],
['AddRecord', 'Data1', null, {A: 501}],
['AddTable', 'Data2', [{id: 'A', type: 'Numeric'},
{id: 'B', isFormula: true, formula: '$A + 1'}]],
['AddRecord', 'Data2', null, {A: 101}],
['AddRecord', 'Data2', null, {A: 201}],
['AddRecord', 'Data2', null, {A: 301}],
['AddRecord', 'Data2', null, {A: 401}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data2', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'newRec.B % 2 != 0', permissionsText: '-CU',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'newRec.B % 2 != 0', permissionsText: '-CU',
}],
]);
// It is ok for rows to temporarily disobey newRec constraint.
await assert.isFulfilled(owner.applyUserActions(docId, [
['UpdateRecord', 'Data1', 1, {A: 91}],
['UpdateRecord', 'Data1', 2, {A: 92}],
['UpdateRecord', 'Data1', 2, {A: 93}],
['UpdateRecord', 'Data1', 3, {A: 94}],
['UpdateRecord', 'Data2', 4, {A: 96}],
['UpdateRecord', 'Data2', 4, {A: 97}],
['AddRecord', 'Data2', 5, {}],
['UpdateRecord', 'Data2', 5, {A: 99}],
['UpdateRecord', 'Data1', 3, {A: 95}],
]));
// newRec behavior survives table renames.
await assert.isFulfilled(owner.applyUserActions(docId, [
['UpdateRecord', 'Data1', 2, {A: 6}],
['RenameTable', 'Data1', 'Data11'],
['UpdateRecord', 'Data11', 2, {A: 7}],
]));
// newRec behavior cannot at this time survive column renames.
await assert.isRejected(owner.applyUserActions(docId, [
['UpdateRecord', 'Data11', 2, {A: 4}],
['RenameColumn', 'Data11', 'B', 'BB'],
['UpdateRecord', 'Data11', 2, {A: 5}],
]), /Blocked by row update access rules/);
});
it('rules survive schema changes within a bundle', async function() {
// This is important because of renames, which propagate to ACL resources and rules.
// But then again, not that important since in-bundle changes are funky because of
// delayed formula updates.
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
{id: 'B', type: 'Numeric'}]],
['AddRecord', 'Data1', null, {A: 0, B: 0}],
['AddTable', 'Data2', [{id: 'A', type: 'Numeric'}]],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'A'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.B > 0', permissionsText: '+U', memo: 'me I did it',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: '', permissionsText: '-U',
}],
]);
await assert.isFulfilled(owner.applyUserActions(docId, [
['UpdateRecord', 'Data1', 1, {B: 1}],
['UpdateRecord', 'Data1', 1, {A: 20}],
['UpdateRecord', 'Data1', 1, {B: 2}],
['RenameColumn', 'Data1', 'B', 'BB'],
['RenameTable', 'Data1', 'Data11'],
['UpdateRecord', 'Data11', 1, {A: 21}],
['RenameColumn', 'Data11', 'BB', 'B'],
['RenameTable', 'Data11', 'Data1'],
]));
await assert.isRejected(owner.applyUserActions(docId, [
['UpdateRecord', 'Data1', 1, {B: 1}],
['UpdateRecord', 'Data1', 1, {A: 20}],
['UpdateRecord', 'Data1', 1, {B: 0}],
['RenameColumn', 'Data1', 'B', 'BB'],
['RenameTable', 'Data1', 'Data11'],
['UpdateRecord', 'Data11', 1, {A: 21}],
['RenameColumn', 'Data11', 'BB', 'B'],
['RenameTable', 'Data11', 'Data1'],
]), /Blocked by .* access rules/);
});
it('can limit workflow', async function() {
await freshDoc();
// Make a table with a choice column containing PENDING, STARTED, and FINISHED, with
// only modification allowed to that column being to increment it.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'Status', type: 'Choice'},
{id: 'StatusIndex', isFormula: true,
formula: 'try:\n\treturn ["PENDING", "STARTED", "FINISHED"]' +
'.index($Status)\nexcept:\n\treturn -1'}]],
['AddRecord', 'Data1', null, {Status: 'PENDING'}],
['AddRecord', 'Data1', null, {Status: 'STARTED'}],
['AddRecord', 'Data1', null, {Status: 'FINISHED'}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'Status'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'newRec.StatusIndex <= rec.StatusIndex', permissionsText: '-U',
}],
]);
const api = owner.getDocAPI(docId);
// PENDING -> STARTED allowed.
await assert.isFulfilled(api.updateRows('Data1', { id: [1], Status: ['STARTED'] }));
// STARTED -> PENDING forbidden.
await assert.isRejected(api.updateRows('Data1', { id: [1], Status: ['PENDING'] }));
// STARTED -> FINISHED allowed.
await assert.isFulfilled(api.updateRows('Data1', { id: [1], Status: ['FINISHED'] }));
// FINISHED -> earlier state forbidden.
await assert.isRejected(api.updateRows('Data1', { id: [1], Status: ['STARTED'] }));
await assert.isRejected(api.updateRows('Data1', { id: [1], Status: ['PENDING'] }));
await assert.isRejected(api.updateRows('Data1', { id: [1], Status: ['...'] }));
// This next "change" succeeds because the user action is translated into a no-op
// by the data engine, and that no-op is permitted.
await assert.isFulfilled(api.updateRows('Data1', { id: [1], Status: ['FINISHED'] }));
});
it('respects user-private tables', async function() {
await freshDoc();
const editorProfile = await editor.getUserProfile();
// Make a Private table and mark it as user-only (using temporary representation).
// Make a Public table without any particular access control.
await owner.applyUserActions(docId, [
['AddTable', 'Private', [{id: 'A'}]],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Private', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1,
aclFormula: `user.UserID == ${editorProfile.id}`,
permissionsText: 'all',
memo: 'editor check',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: '', permissionsText: 'none',
}],
['AddTable', 'Public', [{id: 'A'}]],
]);
// Owner can access only the public table.
await assertDeniedFor(owner.getDocAPI(docId).getRows('Private'), ['editor check']);
await assert.isFulfilled(owner.getDocAPI(docId).getRows('Public'));
// Editor can access both tables.
await assert.isFulfilled(editor.getDocAPI(docId).getRows('Private'));
await assert.isFulfilled(editor.getDocAPI(docId).getRows('Public'));
// There are a lot of things the owner can still do, because they are
// an owner - including downloading doc, changing access rules etc, editing
// the table. But the table will be hidden in the client, making it difficult
// to accidentally edit/view through it at least.
});
it('allows characteristic tables', async function() {
await freshDoc();
const editorProfile = await editor.getUserProfile();
await owner.applyUserActions(docId, [
['AddTable', 'Seattle', [{id: 'A'}]],
['AddTable', 'Zones', [{id: 'Email'}, {id: 'City'}]],
['AddRecord', 'Zones', null, {Email: editorProfile.email, City: 'Seattle'}],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Seattle', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -3, {tableId: 'Zones', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, userAttributes: JSON.stringify({
name: 'Zone',
tableId: 'Zones',
charId: 'Email',
lookupColId: 'Email',
})
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2,
aclFormula: 'user.Zone.City != "Seattle"',
permissionsText: 'none',
memo: 'city check',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -3,
aclFormula: 'user.Access != "owners"',
permissionsText: 'none',
memo: 'owner check',
}],
]);
await assertDeniedFor(owner.getDocAPI(docId).getRows('Seattle'), ['city check']);
await assert.isFulfilled(owner.getDocAPI(docId).getRows('Zones'));
await assert.isFulfilled(editor.getDocAPI(docId).getRows('Seattle'));
await assertDeniedFor(editor.getDocAPI(docId).getRows('Zones'), ['owner check']);
});
it('allows characteristic tables to control row access', async function() {
await freshDoc();
const ownerProfile = await owner.getUserProfile();
const editorProfile = await editor.getUserProfile();
await owner.applyUserActions(docId, [
['AddTable', 'Leads', [{id: 'Name'}, {id: 'Place'}]],
['AddRecord', 'Leads', null, {Name: 'Yi Wen', Place: 'Seattle'}],
['AddRecord', 'Leads', null, {Name: 'Zeng Hua', Place: 'Seattle'}],
['AddRecord', 'Leads', null, {Name: 'Tao Ping', Place: 'Boston'}],
['AddTable', 'Zones', [{id: 'Email'}, {id: 'City'}]],
['AddRecord', 'Zones', null, {Email: editorProfile.email, City: 'Seattle'}],
['AddRecord', 'Zones', null, {Email: ownerProfile.email, City: 'Boston'}],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Leads', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, userAttributes: JSON.stringify({
name: 'Zone',
tableId: 'Zones',
charId: 'Email',
lookupColId: 'Email',
})
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'user.Zone.City != rec.Place', permissionsText: 'none',
}],
]);
// Editor sees Seattle rows.
assert.deepEqual((await editor.getDocAPI(docId).getRows('Leads')).id, [1, 2]);
// Owner sees Boston rows.
assert.deepEqual((await owner.getDocAPI(docId).getRows('Leads')).id, [3]);
});
it('respects column level access denial', async function() {
await freshDoc();
// Make a table with 4 columns, only 2 of which should be available to non-owners.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'}, {id: 'B', type: 'Numeric'},
{id: 'C', isFormula: true, formula: '$A + $B'},
{id: 'D', isFormula: true, formula: '$A - $B'}]],
['AddRecord', 'Data1', null, {A: 10, B: 4}],
['AddRecord', 'Data1', null, {A: 20, B: 5}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'A,C'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: 'none',
}],
]);
const expect: TableColValues = {
id: [1, 2],
manualSort: [1, 2],
A: [10, 20],
B: [4, 5],
C: [14, 25],
D: [6, 15],
};
assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')), expect);
delete expect.A;
delete expect.C;
assert.deepEqual((await editor.getDocAPI(docId).getRows('Data1')), expect);
});
it('respects column level access granting', async function() {
await freshDoc();
// Make a table with 4 columns, only 2 of which should be available to non-owners.
// Flips previous test by defaulting to denying columns, then granting access to
// those we want to share (rather than denying individual columns we don't wish to
// share).
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'}, {id: 'B', type: 'Numeric'},
{id: 'C', isFormula: true, formula: '$A + $B'},
{id: 'D', isFormula: true, formula: '$A - $B'}]],
['AddRecord', 'Data1', null, {A: 10, B: 4}],
['AddRecord', 'Data1', null, {A: 20, B: 5}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: 'B,D'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: '', permissionsText: 'all',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners"', permissionsText: 'none',
}],
]);
const expect: TableColValues = {
id: [1, 2],
manualSort: [1, 2],
A: [10, 20],
B: [4, 5],
C: [14, 25],
D: [6, 15],
};
assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')), expect);
delete expect.A;
delete expect.C;
assert.deepEqual((await editor.getDocAPI(docId).getRows('Data1')), expect);
});
it('only respects read+update permissions in column-level rules', async function() {
// Seed rules previously could result in column-level rules that could contain create+delete
// permissions. Even if those appear in rules, we should ignore them.
await freshDoc();
// Create a table with columns A, B. Table denies access, but column A allows all. This
// situation used to be easy to get into with seed rules when they didn't trim permission bits.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'}, {id: 'B', type: 'Numeric'}]],
['AddRecord', 'Data1', null, {A: 10, B: 4}],
['AddRecord', 'Data1', null, {A: 20, B: 5}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: 'A'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: '', permissionsText: '+CRUD',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: '', permissionsText: '+R-UCD',
}],
]);
// Check that we can fetch all data, no restrictions there.
assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')), {
id: [1, 2],
manualSort: [1, 2],
A: [10, 20],
B: [4, 5],
});
// Check that we cannot add or delete records (despite column rule seeming to allow it).
await assert.isRejected(owner.applyUserActions(docId, [
["AddRecord", "Data1", null, {"A": 30}],
]), /Blocked by table create access rules/);
await assert.isRejected(owner.applyUserActions(docId, [
["RemoveRecord", "Data1", 2],
]), /Blocked by table delete access rules/);
// The column rule does its job: allows update to column A.
await owner.applyUserActions(docId, [
["UpdateRecord", "Data1", 2, {"A": 2000}]
]);
// But the table rule applies to column B.
await assert.isRejected(owner.applyUserActions(docId, [
["UpdateRecord", "Data1", 2, {"B": 500}],
]), /Blocked by column update access rules/);
assert.deepEqual((await owner.getDocAPI(docId).getRows('Data1')), {
id: [1, 2],
manualSort: [1, 2],
A: [10, 2000],
B: [4, 5],
});
});
it('always allows Calculate action', async function() {
await freshDoc();
// Make a cell set to `=NOW()` and forbid updating it.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'Now', isFormula: true, formula: 'NOW()'}]],
['AddRecord', 'Data1', null, {}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: '', permissionsText: '-U',
}],
['AddTable', 'Private', [{id: 'A'}]],
]);
const now1 = (await owner.getDocAPI(docId).getRows('Data1')).Now[0];
await owner.getDocAPI(docId).forceReload();
const now2 = (await owner.getDocAPI(docId).getRows('Data1')).Now[0];
assert.notDeepEqual(now1, now2);
});
it('can undo changes partially if all are not permitted', async function() {
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Int'}, // editor has full rights
{id: 'B', type: 'Int'}, // editor can read only
{id: 'C', type: 'Int'}, // editor can edit on some rows
{id: 'D', type: 'Int'}, // editor can edit on some rows
{id: 'E', type: 'Int'}, // editor cannot view or edit
{id: 'F', isFormula: true, formula: '$A'}]], // read only
['AddRecord', 'Data1', null, {A: 10, B: 10, C: 10, D: 10, E: 10}], // x x
['AddRecord', 'Data1', null, {A: 11, B: 11, C: 11, D: 11, E: 11}],
['AddRecord', 'Data1', null, {A: 12, B: 12, C: 12, D: 12, E: 12}], // x
['AddRecord', 'Data1', null, {A: 13, B: 13, C: 13, D: 13, E: 13}], // x
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: 'B'}],
['AddRecord', '_grist_ACLResources', -3, {tableId: 'Data1', colIds: 'C'}],
['AddRecord', '_grist_ACLResources', -4, {tableId: 'Data1', colIds: 'D'}],
['AddRecord', '_grist_ACLResources', -5, {tableId: 'Data1', colIds: 'E'}],
['AddRecord', '_grist_ACLResources', -6, {tableId: 'Data1', colIds: 'F'}],
['AddRecord', '_grist_ACLRules', null, {
// editor can only create or delete rows with A odd.
resource: -1, aclFormula: 'user.Access != OWNER and rec.A % 2 == 1', permissionsText: '-CD',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'user.Access != OWNER', permissionsText: '-U',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -3, aclFormula: 'user.Access != OWNER and rec.id % 2 == 1', permissionsText: '-U',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -4, aclFormula: 'user.Access != OWNER and rec.id % 3 == 1', permissionsText: '-U',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -5, aclFormula: 'user.Access != OWNER', permissionsText: 'none',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -6, aclFormula: 'user.Access != OWNER', permissionsText: '-U',
}],
]);
// Share the document with everyone as an editor.
await owner.updateDocPermissions(docId, { users: { 'everyone@getgrist.com': 'editors' } });
// Check that a (fake) undo that affects only material user has edit rights on works.
const expected = await owner.getDocAPI(docId).getRows('Data1');
await applyAsUndo(cliEditor, [['UpdateRecord', 'Data1', 1, {A: 55}]]);
expected.A[0] = 55;
expected.F[0] = 55;
assert.deepEqual(await owner.getDocAPI(docId).getRows('Data1'), expected);
// Check that an undo that includes a change to a column the user cannot edit has that
// change stripped.
await applyAsUndo(cliEditor, [['UpdateRecord', 'Data1', 1, {A: 56, B: 99, E: 99}]]);
expected.A[0] = 56;
expected.F[0] = 56;
assert.deepEqual(await owner.getDocAPI(docId).getRows('Data1'), expected);
// Check that changes to specific cells the user cannot edit are also stripped.
await applyAsUndo(cliEditor, [['BulkUpdateRecord', 'Data1', [1, 2, 3, 4],
{A: [60, 71, 81, 90],
C: [100, 110, 120, 130],
D: [140, 150, 160, 170]}]]);
expected.F[0] = expected.A[0] = 60;
expected.F[1] = expected.A[1] = 71;
expected.F[2] = expected.A[2] = 81;
expected.F[3] = expected.A[3] = 90;
expected.C[1] = 110;
expected.C[3] = 130;
expected.D[1] = 150;
expected.D[2] = 160;
assert.deepEqual(await owner.getDocAPI(docId).getRows('Data1'), expected);
// Check that adds and removes work or are blocked as expected.
// Editor can only create/delete rows with A odd.
await applyAsUndo(cliEditor, [
['AddRecord', 'Data1', 999, {A: 77}], // should be skipped, A must be even
['BulkRemoveRecord', 'Data1', [1, 2]], // should skip rowId 2, A must be even
]);
for (const key of Object.keys(expected)) {
// Only first row is removed; no addition.
pruneArray(expected[key], [0]);
}
assert.deepEqual(await owner.getDocAPI(docId).getRows('Data1'), expected);
await applyAsUndo(cliEditor, [
['AddRecord', 'Data1', 1000, {A: 88}], // should be allowed, A is even.
['BulkAddRecord', 'Data1', [1001, 1002], {A: [90, 91]}], // first should be allowed
]);
expected.id.push(1000, 1001);
expected.A.push(88, 90);
expected.B.push(0, 0);
expected.C.push(0, 0);
expected.D.push(0, 0);
expected.E.push(0, 0);
expected.F.push(88, 90);
expected.manualSort.push(null, null); // perhaps in a real undo these would have been set in DocActions?
assert.deepEqual(await owner.getDocAPI(docId).getRows('Data1'), expected);
});
it('getAclResources exposes all tableIds and colIds to those with access rules access', async function() {
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'}, {id: 'B', type: 'Numeric'}]],
['AddTable', 'Data2', [{id: 'C', type: 'Numeric'}, {id: 'D', type: 'Numeric'}]],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'A'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data2', colIds: '*'}],
// Nobody gets access.
['AddRecord', '_grist_ACLRules', null, {resource: -1, aclFormula: '', permissionsText: 'none'}],
['AddRecord', '_grist_ACLRules', null, {resource: -2, aclFormula: '', permissionsText: 'none'}],
]);
// Check that the owner does not see the blocked resources normally.
const data1 = await owner.getDocAPI(docId).getRows('Data1');
assert.property(data1, 'B');
assert.notProperty(data1, 'A');
await assert.isRejected(owner.getDocAPI(docId).getRows('Data2'));
// But the owner sees them in getAclResources call. This call is available via the websocket.
assert.deepInclude((await cliOwner.send("getAclResources", 0)).data.tables, {
Data1: {
title: 'Data1',
colIds: ['id', 'manualSort', 'A', 'B'],
groupByColLabels: null
},
Data2: {
title: 'Data2',
colIds: ['id', 'manualSort', 'C', 'D'],
groupByColLabels: null
},
});
// Others can NOT call getAclResources.
assert.match((await cliEditor.send("getAclResources", 0)).error!, /Cannot list ACL resources/);
// Grant access to Access Rules.
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: '*SPECIAL', colIds: 'AccessRules'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access == "editors"', permissionsText: '+R',
}],
]);
// Now others CAN call getAclResources.
assert.deepInclude((await cliEditor.send("getAclResources", 0)).data.tables, {
Data1: {
title: 'Data1',
colIds: ['id', 'manualSort', 'A', 'B'],
groupByColLabels: null
},
Data2: {
title: 'Data2',
colIds: ['id', 'manualSort', 'C', 'D'],
groupByColLabels: null
},
});
});
it('allows column conversions in the presence of per-row rules', async function() {
await freshDoc();
const results = await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A'}, {id: 'locked', type: 'Bool'}]],
['AddColumn', 'Data1', 'B', {type: 'Text', isFormula: false}],
['AddRecord', 'Data1', null, {A: 1, locked: true}],
['AddRecord', 'Data1', null, {A: 2, locked: true}],
['AddRecord', 'Data1', null, {A: 3, locked: false}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'rec.locked and user.Access != "owners"', permissionsText: '+R-CUD',
}],
]);
// Get the metadata rowId of column B in table Data1.
const colRef = results.retValues[1].colRef;
// Cell changes in a column conversion will bypass access control. If the user has the
// permissionn to change the schema, then the column conversion will be permitted.
// (this test used to be more elaborate before this was true).
await assert.isFulfilled(editor.applyUserActions(docId,
[['UpdateRecord', '_grist_Tables_column', colRef, {type: 'Numeric'}]]));
});
// Checks for a bug in filtering first row.
it('can filter out first row correctly', async function() {
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
{id: 'B', type: 'Numeric'},
{id: 'Sum', isFormula: true, formula: '$A + $A'}]],
['AddRecord', 'Data1', null, {A: 100, B: 50}],
['AddRecord', 'Data1', null, {A: 200, B: 150}],
['AddRecord', 'Data1', null, {A: 300, B: 250}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners" and rec.A != 7', permissionsText: '-R',
}],
]);
cliOwner.flush();
cliEditor.flush();
// Change formula, which changes data in all rows, which then all need filtering out.
await owner.applyUserActions(docId, [
['ModifyColumn', 'Data1', 'Sum', {formula: '$A + $B'}]
]);
let fullResult = await cliOwner.readDocUserAction();
let filteredResult = await cliEditor.readDocUserAction();
assert.lengthOf(fullResult, 3);
assert.lengthOf(filteredResult, 2);
assert.deepEqual(fullResult.slice(0, 2), filteredResult);
assert.deepEqual(fullResult[2].slice(0, 2), ['BulkUpdateRecord', 'Data1']);
// Flip on a row to make sure it shows up.
await owner.applyUserActions(docId, [
['UpdateRecord', 'Data1', 3, {A: 7}]
]);
fullResult = await cliOwner.readDocUserAction();
filteredResult = await cliEditor.readDocUserAction();
assert.deepEqual(fullResult, [
[ 'UpdateRecord', 'Data1', 3, { A: 7 } ],
[ 'UpdateRecord', 'Data1', 3, { Sum: 257 } ]
]);
assert.deepEqual(filteredResult, [
[ 'BulkAddRecord', 'Data1', [3], { manualSort: [3], A: [7], B: [250], Sum: [550] } ],
[ 'UpdateRecord', 'Data1', 3, { Sum: 257 } ]
]);
// Flip on first row to make sure it shows up.
await owner.applyUserActions(docId, [
['UpdateRecord', 'Data1', 1, {A: 7}]
]);
fullResult = await cliOwner.readDocUserAction();
filteredResult = await cliEditor.readDocUserAction();
assert.deepEqual(fullResult, [
[ 'UpdateRecord', 'Data1', 1, { A: 7 } ],
[ 'UpdateRecord', 'Data1', 1, { Sum: 57 } ]
]);
assert.deepEqual(filteredResult, [
[ 'BulkAddRecord', 'Data1', [1], { manualSort: [1], A: [7], B: [50], Sum: [150] } ],
[ 'UpdateRecord', 'Data1', 1, { Sum: 57 } ]
]);
});
for (const first of ['editor', 'owner', 'any'] as const) {
it(`can censor specific cells in a column (${first} first)`, async function() {
if (first !== 'any') {
sandbox.stub(DocClientsDeps, 'BROADCAST_ORDER').value('series');
}
// Create some column rules that control read permission based on other columns.
// Add a rule that controls overall row read permission to check it interacts ok.
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A', type: 'Numeric'},
{id: 'B', type: 'Numeric'},
{id: 'C', type: 'Numeric'},
{id: 'D', type: 'Numeric'}]],
['AddRecord', 'Data1', null, {A: 100, B: 1, C: 40, D: 300}],
['AddRecord', 'Data1', null, {A: 200, B: 2, C: 45, D: 200}],
['AddRecord', 'Data1', null, {A: 300, B: 3, C: 50, D: 100}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'C,D'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: 'B'}],
['AddRecord', '_grist_ACLResources', -3, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners" and rec.A < 200', permissionsText: '-R',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'user.Access != "owners" and rec.A < 50', permissionsText: '-R',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -3, aclFormula: 'user.Access != "owners" and rec.B == 99', permissionsText: '-R',
}],
]);
await reopenClients({first});
// Make a series of adds/updates, and make sure cells that are affected indirectly
// are censored or uncensored as appropriate.
cliEditor.flush();
cliOwner.flush();
await owner.getDocAPI(docId).addRows('Data1', {A: [300, 150], B: [1, 1], C: [1, 1], D: [1, 1]});
assert.deepEqual(await cliEditor.readDocUserAction(),
[ [ 'BulkAddRecord',
'Data1',
[4, 5],
{ A: [300, 150], manualSort: [4, 5], B: [1, 1],
C: [1, [GristObjCode.Censored]],
D: [1, [GristObjCode.Censored]] } ] ]);
assert.deepEqual(await cliOwner.readDocUserAction(),
[ [ 'BulkAddRecord',
'Data1',
[4, 5],
{ A: [300, 150], manualSort: [4, 5], B: [1, 1], C: [1, 1], D: [1, 1] } ] ]);
cliEditor.flush();
cliOwner.flush();
await owner.getDocAPI(docId).updateRows('Data1', {id: [4], A: [100]});
assert.deepEqual(await cliEditor.readDocUserAction(),
[ [ 'UpdateRecord', 'Data1', 4, { A: 100 } ],
[ 'BulkUpdateRecord', 'Data1', [ 4 ], { C: [ [GristObjCode.Censored] ] } ],
[ 'BulkUpdateRecord', 'Data1', [ 4 ], { D: [ [GristObjCode.Censored] ] } ] ]);
assert.deepEqual(await cliOwner.readDocUserAction(),
[ [ 'UpdateRecord', 'Data1', 4, { A: 100 } ] ]);
cliEditor.flush();
cliOwner.flush();
await owner.getDocAPI(docId).updateRows('Data1', {id: [4], A: [600]});
assert.deepEqual(await cliEditor.readDocUserAction(),
[ [ 'UpdateRecord', 'Data1', 4, { A: 600 } ],
[ 'BulkUpdateRecord', 'Data1', [ 4 ], { C: [ 1 ] } ],
[ 'BulkUpdateRecord', 'Data1', [ 4 ], { D: [ 1 ] } ] ]);
assert.deepEqual(await cliOwner.readDocUserAction(),
[ [ 'UpdateRecord', 'Data1', 4, { A:600 } ] ]);
cliEditor.flush();
cliOwner.flush();
await owner.getDocAPI(docId).updateRows('Data1', {id: [4], A: [3]});
assert.deepEqual(await cliEditor.readDocUserAction(),
[ [ 'UpdateRecord', 'Data1', 4, { A: 3 } ],
[ 'BulkUpdateRecord', 'Data1', [ 4 ], { B: [ [GristObjCode.Censored] ] } ],
[ 'BulkUpdateRecord', 'Data1', [ 4 ], { C: [ [GristObjCode.Censored] ] } ],
[ 'BulkUpdateRecord', 'Data1', [ 4 ], { D: [ [GristObjCode.Censored] ] } ] ]);
assert.deepEqual(await cliOwner.readDocUserAction(),
[ [ 'UpdateRecord', 'Data1', 4, { A: 3 } ] ]);
cliEditor.flush();
cliOwner.flush();
await owner.getDocAPI(docId).updateRows('Data1', {id: [4], A: [75]});
assert.deepEqual(await cliEditor.readDocUserAction(),
[ [ 'UpdateRecord', 'Data1', 4, { A: 75 } ],
[ 'BulkUpdateRecord', 'Data1', [ 4 ], { B: [ 1 ] } ] ]);
assert.deepEqual(await cliOwner.readDocUserAction(),
[ [ 'UpdateRecord', 'Data1', 4, { A: 75 } ] ]);
cliEditor.flush();
cliOwner.flush();
await owner.getDocAPI(docId).updateRows('Data1', {id: [4], B: [99]});
assert.deepEqual(await cliEditor.readDocUserAction(),
[ [ 'BulkRemoveRecord', 'Data1', [ 4 ] ] ]);
assert.deepEqual(await cliOwner.readDocUserAction(),
[ [ 'UpdateRecord', 'Data1', 4, { B: 99 } ] ]);
cliEditor.flush();
cliOwner.flush();
await owner.getDocAPI(docId).updateRows('Data1', {id: [4], B: [98]});
assert.deepEqual(await cliEditor.readDocUserAction(),
[ [ 'BulkAddRecord',
'Data1',
[ 4 ],
{ manualSort: [ 4 ],
A: [ 75 ],
B: [ 98 ],
C: [ [GristObjCode.Censored] ],
D: [ [GristObjCode.Censored] ] } ] ]);
assert.deepEqual(await cliOwner.readDocUserAction(),
[ [ 'UpdateRecord', 'Data1', 4, { B: 98 } ] ]);
cliEditor.flush();
cliOwner.flush();
await owner.getDocAPI(docId).updateRows('Data1', {id: [1, 2, 4], A: [1, 75, 200]});
assert.deepEqual(await cliEditor.readDocUserAction(),
[ [ 'BulkUpdateRecord',
'Data1',
[ 1, 2, 4 ],
{ A: [ 1, 75, 200 ] } ],
[ 'BulkUpdateRecord', 'Data1', [ 1 ], { B: [ [GristObjCode.Censored] ] } ],
[ 'BulkUpdateRecord',
'Data1',
[ 2, 4 ],
{ C: [ [GristObjCode.Censored], 1 ] } ],
[ 'BulkUpdateRecord',
'Data1',
[ 2, 4 ],
{ D: [ [GristObjCode.Censored], 1 ] } ] ]);
assert.deepEqual(await cliOwner.readDocUserAction(),
[ [ 'BulkUpdateRecord',
'Data1',
[ 1, 2, 4 ],
{ A: [ 1, 75, 200 ] } ] ]);
// Add a formula column to simulate a reported bug (not actually needed to tickle problem)
// where a censored cell for one user could show up as censored for another.
await owner.applyUserActions(docId, [
['AddColumn', 'Data1', 'E', {formula: '$C'}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: 'E'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != "owners" and rec.A < 200', permissionsText: '-R',
}],
]);
cliEditor.flush();
cliOwner.flush();
await editor.getDocAPI(docId).updateRows('Data1', {id: [2], C: [999]});
assert.deepEqual(await cliEditor.readDocUserAction(),
[ [ 'UpdateRecord', 'Data1', 2, { C: [GristObjCode.Censored] } ],
[ 'UpdateRecord', 'Data1', 2, { E: [GristObjCode.Censored] } ] ]);
assert.deepEqual(await cliOwner.readDocUserAction(),
[ [ 'UpdateRecord', 'Data1', 2, { C: 999 } ],
[ 'UpdateRecord', 'Data1', 2, { E: 999 } ] ]);
// Check that only the owner can evaluate the formula.
let response = await cliOwner.send('getFormulaError', 0, 'Data1', 'E', 2);
assert.equal(response.data, 999);
response = await cliEditor.send('getFormulaError', 0, 'Data1', 'E', 2);
assert.equal(response.data, undefined);
assert.equal(response.error, 'Cannot access cell');
assert.equal(response.errorCode, 'ACL_DENY');
});
}
describe('filterColValues', async function() {
// A method for checking if a cell contains 'x'.
function xRemove(val: any) { return val === 'x'; }
for (const actType of ['BulkUpdateRecord', 'BulkAddRecord', 'ReplaceTableData', 'TableData'] as const) {
it(`should remove correct elements for ${actType}`, function() {
// Prepare a 1 row bulk action.
const action1: BulkUpdateRecord|BulkAddRecord|ReplaceTableData|TableDataAction = [
actType,
'Table1',
[1],
{
a: ['x'], b: ['b'], c: ['x']
}
];
// Check the action is unchanged if row is not specified for filtering.
assert.deepEqual(filterColValues(cloneDeep(action1), (idx) => idx === 99, xRemove),
[action1]);
// Check the action is filtered as expected if row is specified. Action set returned
// is suboptimal, but nevertheless as expected.
assert.deepEqual(filterColValues(cloneDeep(action1), (idx) => idx === 0, xRemove),
[[actType, 'Table1', [], {a: [], b: [], c: []}],
[actType, 'Table1', [1], {b: ['b']}]]);
// Prepare a multi-row bulk action.
const action2: typeof action1 = [
actType,
'Table1',
[1, 2, 3],
{
a: ['x', 'a', 'a'], b: ['b', 'b', 'b'], c: ['x', 'c', 'x']
}
];
// Check filtering is as expected: one retained row, two new actions for the
// two new permutations of columns.
assert.deepEqual(filterColValues(cloneDeep(action2), (idx) => idx % 2 === 0, xRemove),
[[actType, 'Table1', [2], {a: ['a'], b: ['b'], c: ['c']}],
[actType, 'Table1', [3], {a: ['a'], b: ['b']}],
[actType, 'Table1', [1], {b: ['b']}]]);
// Prepare a many-row bulk action, and check filtering is as expected.
const action3: typeof action1 = [
actType,
'Table1',
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
{
a: ['a', 'a', 'a', 'a', 'x', 'x', 'x', 'x', 'A', 'A', 'A', 'A'],
b: ['b', 'b', 'x', 'x', 'b', 'b', 'x', 'x', 'B', 'B', 'x', 'x'],
c: ['c', 'x', 'c', 'x', 'c', 'x', 'c', 'x', 'C', 'x', 'C', 'x'],
}
];
assert.deepEqual(filterColValues(cloneDeep(action3), (idx) => ![0, 8].includes(idx), xRemove),
[[actType, 'Table1', [1, 9], {a: ['a', 'A'], b: ['b', 'B'], c: ['c', 'C']}],
[actType, 'Table1', [8], {}],
[actType, 'Table1', [4, 12], {a: ['a', 'A']}],
[actType, 'Table1', [2, 10], {a: ['a', 'A'], b: ['b', 'B']}],
[actType, 'Table1', [3, 11], {a: ['a', 'A'], c: ['c', 'C']}],
[actType, 'Table1', [6], {b: ['b']}],
[actType, 'Table1', [5], {b: ['b'], c: ['c']}],
[actType, 'Table1', [7], {c: ['c']}]]);
});
}
for (const actType of ['UpdateRecord', 'AddRecord'] as const) {
it(`should remove correct elements for ${actType}`, function() {
const action1: UpdateRecord|AddRecord = [
actType,
'Table1',
1,
{
a: 'x', b: 'b', c: 'x'
}
];
assert.deepEqual(filterColValues(cloneDeep(action1), (idx) => idx === 0, xRemove),
[[actType, 'Table1', 1, {b: 'b'}]]);
// shouldFilterRow is somewhat arbitrarily ignored for non-bulk changes.
assert.deepEqual(filterColValues(cloneDeep(action1), (idx) => idx === 99, xRemove),
[[actType, 'Table1', 1, {b: 'b'}]]);
});
}
it('should not remove anything for BulkRemoveRecord', function() {
const action1: BulkRemoveRecord = ['BulkRemoveRecord', 'Table1', [1, 2, 3]];
assert.deepEqual(filterColValues(cloneDeep(action1), (idx) => idx === 0, xRemove), [action1]);
});
it('should not remove anything for RemoveRecord', function() {
const action1: RemoveRecord = ['RemoveRecord', 'Table1', 1];
assert.deepEqual(filterColValues(cloneDeep(action1), (idx) => idx === 0, xRemove), [action1]);
});
});
it('respects exceptional sessions for reading', async function() {
const activeDoc = await docTools.createDoc('test-doc');
// Make an exceptional session with full unconditional access.
const systemSession = makeExceptionalDocSession('system');
// Make a fake regular session with access-rule-dependent access.
const userSession = {
client: null,
req: {
docAuth: {
access: 'viewers',
},
user: {id: 1, logins: [{displayEmail: 'someone@getgrist.com'}]},
get: () => null,
} as any
};
// Deny everyone access to Table1, and a column and row of Table2.
await activeDoc.applyUserActions(systemSession, [
['AddTable', 'Table1', [{id: 'A'}, {id: 'B'}]],
['AddRecord', 'Table1', null, {A: 2021, B: 'kangaroo'}],
['AddTable', 'Table2', [{id: 'A'}, {id: 'B'}]],
['AddRecord', 'Table2', null, {A: 2022, B: 'wallaby'}],
['AddRecord', 'Table2', null, {A: -1, B: 'koala'}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Table1', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Table2', colIds: 'B'}],
['AddRecord', '_grist_ACLResources', -3, {tableId: 'Table2', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'True', permissionsText: 'none',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'True', permissionsText: 'none',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -3, aclFormula: 'rec.A < 0', permissionsText: 'none',
}],
]);
// Check that exceptional session has full access to Table1 anyway.
assert.deepEqual((await activeDoc.fetchTable(systemSession, 'Table1')).tableData,
[ 'TableData', 'Table1', [ 1 ],
{ manualSort: [ 1 ], A: [ '2021' ], B: [ 'kangaroo' ] } ]);
// Check that regular session does not have access to Table1.
await assert.isRejected(activeDoc.fetchTable(userSession, 'Table1'),
/Blocked by table read access rules/);
// Check that exceptional session has full access to Table2 anyway.
assert.deepEqual((await activeDoc.fetchTable(systemSession, 'Table2')).tableData,
[ 'TableData', 'Table2', [ 1, 2 ],
{ manualSort: [ 1, 2 ], A: [ '2022', '-1' ], B: [ 'wallaby', 'koala' ] } ]);
// Check that regular session does not have full access to Table2.
assert.deepEqual((await activeDoc.fetchTable(userSession, 'Table2')).tableData,
[ 'TableData', 'Table2', [ 1 ],
{ manualSort: [ 1 ], A: [ '2022' ] } ]);
});
for (const flags of ['-R', '-RS']) {
it(`can receive metadata updates even if there is a default ${flags} rule`, async function() {
await freshDoc();
// Make a document with a default rule forbidding editor from reading anything.
await owner.applyUserActions(docId, [
['AddTable', 'Private', [{id: 'A'}]],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: `user.Access != OWNER`, permissionsText: flags,
}],
]);
// Add an extra table, and capture the update sent to the editor.
cliEditor.flush();
await owner.applyUserActions(docId, [
['AddTable', 'Private2', [{id: 'A'}]],
]);
const msg = await cliEditor.readMessage();
// Make sure we saw something.
assert.isAbove(msg?.data?.docActions.length, 10);
// Make sure everything we saw was metadata, and the Private2 AddTable
// action itself did not slip through.
assert.equal((msg?.data?.docActions as Array<DocAction>)
.every(a => a[1].startsWith('_grist')), true);
});
}
it('can enumerate and use "View As" users', async function() {
await freshDoc();
// Check that "View As" users cover users the document is shared with, and
// example users.
cliOwner.flush();
let perm: PermissionDataWithExtraUsers = (await cliOwner.send("getUsersForViewAs", 0)).data;
const getId = (name: string) => home.dbManager.testGetId(name) as Promise<number>;
const getRef = (email: string) => home.dbManager.getUserByLogin(email).then(user => user!.ref);
assert.deepEqual(perm.users, [
{ id: await getId('Chimpy'), email: 'chimpy@getgrist.com', name: 'Chimpy',
ref: await getRef('chimpy@getgrist.com'),
picture: null, access: 'owners', isMember: true },
{ id: await getId('Kiwi'), email: 'kiwi@getgrist.com', name: 'Kiwi',
ref: await getRef('kiwi@getgrist.com'),
picture: null, access: 'owners', isMember: false },
{ id: await getId('Charon'), email: 'charon@getgrist.com', name: 'Charon',
ref: await getRef('charon@getgrist.com'),
picture: null, access: 'editors', isMember: false },
]);
assert.deepEqual(perm.attributeTableUsers, []);
assert.deepEqual(perm.exampleUsers[0],
{ id: 0, email: 'owner@example.com', name: 'Owner', access: 'owners' });
// Add a user attribute table mentioning some users the doc is shared with and
// some novel users.
await owner.applyUserActions(docId, [
['AddTable', 'Leads', [{id: 'Name'}, {id: 'Place'}]],
['AddRecord', 'Leads', null, {Name: 'Yi Wen', Place: 'Seattle'}],
['AddRecord', 'Leads', null, {Name: 'Zeng Hua', Place: 'Boston'}],
['AddRecord', 'Leads', null, {Name: 'Tao Ping', Place: 'Cambridge'}],
['AddTable', 'Zones', [{id: 'Email'}, {id: 'City'}]],
['AddRecord', 'Zones', null, {Email: 'chimpy@getgrist.com', City: 'Seattle'}],
['AddRecord', 'Zones', null, {Email: 'charon@getgrist.com', City: 'Boston'}],
['AddRecord', 'Zones', null, {Email: 'fast@speed.com', City: 'Cambridge'}],
['AddRecord', 'Zones', null, {Email: 'slow@speed.com', City: 'Springfield'}],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Leads', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, userAttributes: JSON.stringify({
name: 'Zone',
tableId: 'Zones',
charId: 'Email',
lookupColId: 'Email',
}),
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'user.Zone.City and user.Zone.City != rec.Place', permissionsText: 'none',
}],
]);
// Check that "View As" users now in addition have the novel user attribute table
// users.
cliOwner.flush();
perm = (await cliOwner.send("getUsersForViewAs", 0)).data;
assert.deepEqual(perm.users, [
{ id: await getId('Chimpy'), email: 'chimpy@getgrist.com', name: 'Chimpy',
ref: await getRef('chimpy@getgrist.com'),
picture: null, access: 'owners', isMember: true },
{ id: await getId('Kiwi'), email: 'kiwi@getgrist.com', name: 'Kiwi',
ref: await getRef('kiwi@getgrist.com'),
picture: null, access: 'owners', isMember: false },
{ id: await getId('Charon'), email: 'charon@getgrist.com', name: 'Charon',
ref: await getRef('charon@getgrist.com'),
picture: null, access: 'editors', isMember: false },
]);
assert.deepEqual(perm.attributeTableUsers, [
{ id: 0, email: 'fast@speed.com', name: 'fast', access: 'editors' },
{ id: 0, email: 'slow@speed.com', name: 'slow', access: 'editors' },
]);
assert.deepEqual(perm.exampleUsers[0],
{ id: 0, email: 'owner@example.com', name: 'Owner', access: 'owners' });
// Add a second user attribute table, this time also with names and access levels.
await owner.applyUserActions(docId, [
['AddTable', 'Users', [{id: 'Email2'}, {id: 'Name'}, {id: 'Access'}]],
['AddRecord', 'Users', null, {Email2: 'red@color.com', Name: 'Rita', Access: 'owners'}],
['AddRecord', 'Users', null, {Email2: 'green@color.com', Name: 'Gary', Access: 'editors'}],
['AddRecord', 'Users', null, {Email2: 'blue@color.com', Name: 'Beatrix', Access: 'viewers'}],
['AddRecord', 'Users', null, {Email2: 'yellow@color.com', Name: 'Yan', Access: null}],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, userAttributes: JSON.stringify({
name: 'More',
tableId: 'Users',
charId: 'Email',
lookupColId: 'Email2',
})
}],
]);
// Check the new users get added as "View As" options.
cliOwner.flush();
perm = (await cliOwner.send("getUsersForViewAs", 0)).data;
assert.deepEqual(perm.attributeTableUsers, [
{ id: 0, email: 'fast@speed.com', name: 'fast', access: 'editors' },
{ id: 0, email: 'slow@speed.com', name: 'slow', access: 'editors' },
{ id: 0, email: 'red@color.com', name: 'Rita', access: 'owners' },
{ id: 0, email: 'green@color.com', name: 'Gary', access: 'editors' },
{ id: 0, email: 'blue@color.com', name: 'Beatrix', access: 'viewers' },
{ id: 0, email: 'yellow@color.com', name: 'Yan', access: null },
]);
// Check that doing a "View As" as a user from the first user attribute table works
// as expected (the user is an editor, and has the expected user attributes in rules).
await reopenClients({linkParameters: {aclAsUser: 'fast@speed.com'}});
cliOwner.flush();
assert.deepEqual((await cliOwner.send('fetchTable', 0, 'Leads')).data.tableData,
[ 'TableData', 'Leads', [ 3 ],
{ manualSort: [ 3 ], Place: [ 'Cambridge' ], Name: [ 'Tao Ping' ] } ]);
assert.deepEqual((await cliOwner.send('applyUserActions', 0,
[['UpdateRecord', 'Leads', 3, {Name: 'Tao'}]])).data,
{ actionNum: 4, retValues: [ null ], isModification: true });
assert.match((await cliOwner.send('applyUserActions', 0,
[['UpdateRecord', 'Leads', 2, {Name: 'Zao'}]])).error!,
/Blocked by row update access rules/);
// Check that doing a "View As" as a user from the second user attribute table works
// as expected (the user has the specified access level, "viewers" in this case).
await reopenClients({linkParameters: {aclAsUser: 'blue@color.com'}});
cliOwner.flush();
assert.deepEqual((await cliOwner.send('fetchTable', 0, 'Leads')).data.tableData,
[ 'TableData', 'Leads', [ 1, 2, 3 ],
{ manualSort: [ 1, 2, 3 ],
Place: [ 'Seattle', 'Boston', 'Cambridge' ],
Name: [ 'Yi Wen', 'Zeng Hua', 'Tao' ] } ]);
assert.match((await cliOwner.send('applyUserActions', 0,
[['UpdateRecord', 'Leads', 2, {Name: 'Zao'}]])).error!,
/Blocked by table update access rules/);
// Check that doing a "View As" as a dummy user works as expected.
await reopenClients({linkParameters: {aclAsUser: 'viewer@example.com'}});
cliOwner.flush();
assert.match((await cliOwner.send('applyUserActions', 0,
[['UpdateRecord', 'Leads', 2, {Name: 'Zao'}]])).error!,
/Blocked by table update access rules/);
await reopenClients({linkParameters: {aclAsUser: 'owner@example.com'}});
cliOwner.flush();
assert.deepEqual((await cliOwner.send('applyUserActions', 0,
[['UpdateRecord', 'Leads', 2, {Name: 'Zao'}]])).data,
{ actionNum: 5, retValues: [ null ], isModification: true });
await reopenClients({linkParameters: {aclAsUser: 'unknown@example.com'}});
cliOwner.flush();
assert.match((await cliOwner.send('applyUserActions', 0,
[['UpdateRecord', 'Leads', 2, {Name: 'Gao'}]])).error!,
/Blocked by table update access rules/);
assert.match((await cliOwner.send('fetchTable', 0, 'Leads')).error!,
/Blocked by table read access rules/);
// Check that doing a "View As" a user the doc is shared with works as expected.
await reopenClients({linkParameters: {aclAsUser: 'charon@getgrist.com'}});
cliOwner.flush();
assert.deepEqual((await cliOwner.send('fetchTable', 0, 'Leads')).data.tableData,
[ 'TableData', 'Leads', [ 2 ],
{ manualSort: [ 2 ], Place: [ 'Boston' ], Name: [ 'Zao' ] } ]);
// Check that doing a "View As" an unknown user works reasonably
await reopenClients({linkParameters: {aclAsUser: 'mystery@getgrist.com'}});
cliOwner.flush();
assert.match((await cliOwner.send('fetchTable', 0, 'Leads')).error!,
/Blocked by table read access rules/);
});
it('controls read and write access to attachment content', async function() {
await freshDoc();
// Make a table, with attachments, and with non-owners missing access to a row.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A'},
{id: 'B'},
{id: 'Pics', type: 'Attachments'},
{id: 'Public', isFormula: true, formula: '$B == "clear"'}]],
['AddRecord', 'Data1', null, {A: 'near', B: 'clear'}],
['AddRecord', 'Data1', null, {A: 'far', B: 'notclear'}],
['AddRecord', 'Data1', null, {A: 'in a motor car', B: 'clear'}],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != OWNER and not rec.Public', permissionsText: 'none',
}],
]);
// Add some attachments.
const i1 = await owner.getDocAPI(docId).uploadAttachment('data1', '1.png');
const i2 = await owner.getDocAPI(docId).uploadAttachment('data2', '2.png');
const i3 = await owner.getDocAPI(docId).uploadAttachment('data3', '3.png');
const i4 = await owner.getDocAPI(docId).uploadAttachment('data4', '4.png');
await owner.getDocAPI(docId).updateRows('Data1', {id: [1], Pics: [[GristObjCode.List, i1, i2]]});
await owner.getDocAPI(docId).updateRows('Data1', {id: [2], Pics: [[GristObjCode.List, i3]]});
await owner.getDocAPI(docId).updateRows('Data1', {id: [3], Pics: [[GristObjCode.List, i4]]});
// Share the document with everyone as an editor.
await owner.updateDocPermissions(docId, { users: { 'everyone@getgrist.com': 'editors' } });
// Check an editor can only access the attachments we expect.
assert.equal(await getAttachment(editor, docId, i1), 'data1');
assert.equal(await getAttachment(editor, docId, i2), 'data2');
await assert.isRejected(getAttachment(editor, docId, i3), /403.*Cannot access attachment/);
assert.equal(await getAttachment(editor, docId, i4), 'data4');
// Add another table with an attachment column, leaving access open.
await owner.applyUserActions(docId, [
['AddTable', 'Data2', [{id: 'MorePics', type: 'Attachments'},
{id: 'Unrelated', type: 'RefList:Data2'}]],
['AddRecord', 'Data2', null, {}],
]);
// Check that user can't gain access to an attachment by writing its id into a cell.
await assert.isRejected(getAttachment(editor, docId, i3), /403.*Cannot access attachment/);
await assert.isRejected(editor.getDocAPI(docId).updateRows(
'Data2',
{id: [1], MorePics: [[GristObjCode.List, i3]]}
), /403.*Cannot access attachment/);
// Don't allow even sticking in an id in an unexpected format.
await assert.isRejected(editor.getDocAPI(docId).updateRows(
'Data2',
{id: [1], MorePics: [i3]}
), /403.*Cannot access attachment/);
await assert.isRejected(editor.getDocAPI(docId).updateRows(
'Data2',
{id: [1], MorePics: [[GristObjCode.List, i2, i3]]}
), /403.*Cannot access attachment/);
await assert.isFulfilled(editor.getDocAPI(docId).updateRows(
'Data2',
{id: [1], MorePics: [[GristObjCode.List, i2]]}
));
// Check no confusion between columns.
await assert.isFulfilled(editor.getDocAPI(docId).updateRows(
'Data2',
{id: [1], MorePics: [[GristObjCode.List, i1]], Unrelated: [[GristObjCode.List, i3]]}
));
await assert.isRejected(editor.getDocAPI(docId).updateRows(
'Data2',
{id: [1], MorePics: [[GristObjCode.List, i3]], Unrelated: [[GristObjCode.List, i2]]}
), /403.*Cannot access attachment/);
// Check that user can add attachments they just uploaded.
const i5 = await editor.getDocAPI(docId).uploadAttachment('data5', '5.png');
await assert.isFulfilled(editor.getDocAPI(docId).updateRows(
'Data2',
{id: [1], MorePics: [[GristObjCode.List, i5]]}
));
// Check that non-owner cannot add attachments uploaded by someone else.
const i6 = await owner.getDocAPI(docId).uploadAttachment('data6', '6.png');
await assert.isRejected(editor.getDocAPI(docId).updateRows(
'Data2',
{id: [1], MorePics: [[GristObjCode.List, i6]]}
), /403.*Cannot access attachment/);
// Attachment check is not applied for undos of actions by the same user.
const ownerProfile = await owner.getUserProfile();
const editorProfile = await editor.getUserProfile();
const ownerInfo = {
user: ownerProfile.email,
time: Date.now(),
};
const editorInfo = {
user: editorProfile.email,
time: Date.now(),
};
// Owner mismatch case.
assert.match((await applyAsUndo(cliEditor, [['UpdateRecord', 'Data2', 1, {MorePics: [GristObjCode.List, i6]}]],
ownerInfo))?.error || '',
/Cannot access attachment/);
// Old action case.
assert.match((await applyAsUndo(cliEditor, [['UpdateRecord', 'Data2', 1, {MorePics: [GristObjCode.List, i6]}]],
{...editorInfo, time: editorInfo.time - 48 * 60 * 60 * 1000}))?.error || '',
/Cannot access attachment/);
// Good case.
assert.equal((await applyAsUndo(cliEditor, [['UpdateRecord', 'Data2', 1, {MorePics: [GristObjCode.List, i6]}]],
editorInfo))?.error || '', '');
// Check that adding an attachment to a cell a user has access to
// will grant them access to the attachment's contents.
await assert.isRejected(getAttachment(editor, docId, i3), /403.*Cannot access attachment/);
await owner.getDocAPI(docId).updateRows('Data2', {id: [1], MorePics: [[GristObjCode.List, i3]]});
assert.equal(await getAttachment(editor, docId, i3), 'data3');
});
it('can add attachments when there are row-level rules', async function() {
await freshDoc();
// Make a table, with attachments, and with row-level edit rights.
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'A'},
{id: 'Pics', type: 'Attachments'}]],
['AddRecord', 'Data1', null, {A: 'edit'}],
['AddRecord', 'Data1', null, {A: 'read'}],
['AddRecord', 'Data1', null, {A: ''}],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Data1', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access != OWNER', permissionsText: 'none',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'user.Access == OWNER', permissionsText: '+RUCD',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: '$A == "edit"', permissionsText: '+RUCD',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: '$A == "read"', permissionsText: '+R-UCD',
}],
]);
// Share the document with everyone as an editor.
await owner.updateDocPermissions(docId, { users: { 'everyone@getgrist.com': 'editors' } });
let attachments = cliOwner.getMetaRecords('_grist_Attachments');
assert.lengthOf(attachments, 0);
// Add an attachment as an owner.
const i1 = await owner.getDocAPI(docId).uploadAttachment('data1', '1.png');
await owner.getDocAPI(docId).updateRows('Data1', {id: [1],
Pics: [[GristObjCode.List, i1]]});
await cliOwner.waitForServer();
await cliEditor.waitForServer();
attachments = cliOwner.getMetaRecords('_grist_Attachments');
assert.lengthOf(attachments, 1);
attachments = cliEditor.getMetaRecords('_grist_Attachments');
assert.lengthOf(attachments, 1); // record is visible to everyone because A is 'edit'
// Check an editor can add an attachment on an allowed row. Check that
// when doing so, the editor receives attachment metadata along with the
// attachment cell change.
cliEditor.flush();
cliOwner.flush();
const i2 = await editor.getDocAPI(docId).uploadAttachment('data2', '2.png');
// Owner should see attachment info already (no filtering for them).
let msg = await cliOwner.readMessage();
let gristAttachmentAction: any[] = msg.data.docActions[0];
assert.deepEqual(gristAttachmentAction.slice(0, 3),
['AddRecord', '_grist_Attachments', 2]);
await cliOwner.waitForServer();
await cliEditor.waitForServer();
attachments = cliOwner.getMetaRecords('_grist_Attachments');
assert.lengthOf(attachments, 2);
// Editor should not (need to wait until attachment is "in" a cell they can access).
attachments = cliEditor.getMetaRecords('_grist_Attachments');
assert.lengthOf(attachments, 1);
cliEditor.flush();
// Add the attachment in a cell. Editor should receive metadata at this point.
await editor.getDocAPI(docId).updateRows('Data1', {id: [1], Pics: [[GristObjCode.List, i1, i2]]});
msg = await cliEditor.readMessage();
gristAttachmentAction = msg.data.docActions[0];
const gristCellAction: any[] = msg.data.docActions[1];
assert.deepEqual(gristAttachmentAction.slice(0, 3),
['BulkAddRecord', '_grist_Attachments', [1, 2]]);
assert.deepEqual(gristCellAction.slice(0, 3),
['UpdateRecord', 'Data1', 1]);
await cliEditor.waitForServer();
attachments = cliEditor.getMetaRecords('_grist_Attachments');
assert.lengthOf(attachments, 2);
// Check an editor cannot add an attachment on a forbidden row.
await assert.isRejected(editor.getDocAPI(docId).updateRows('Data1', {id: [2], Pics: [[GristObjCode.List, i2]]}),
/Blocked by row update access rules/);
// Check if an attachment is added to a cell the editor cannot read, they aren't
// told about it.
const i3 = await owner.getDocAPI(docId).uploadAttachment('data3', '3.png');
await owner.getDocAPI(docId).updateRows('Data1', {id: [3], Pics: [[GristObjCode.List, i3]]});
await cliOwner.waitForServer();
await cliEditor.waitForServer();
attachments = cliOwner.getMetaRecords('_grist_Attachments');
assert.lengthOf(attachments, 3);
attachments = cliEditor.getMetaRecords('_grist_Attachments');
assert.lengthOf(attachments, 2);
// Now tell them.
await owner.getDocAPI(docId).updateRows('Data1', {id: [3], A: ['read']});
msg = await cliEditor.readMessage();
gristAttachmentAction = msg.data.docActions[0];
assert.deepEqual(gristAttachmentAction.slice(0, 3),
['BulkAddRecord', '_grist_Attachments', [3]]);
await cliEditor.waitForServer();
attachments = cliEditor.getMetaRecords('_grist_Attachments');
assert.lengthOf(attachments, 3);
});
it('has access to user reference variable', async function() {
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Data', [{id: 'A'}]],
]);
// Test that ACL rules works as usual.
await assert.isFulfilled(owner.applyUserActions(docId, [['AddRecord', 'Data', null, {}]]));
await assert.isFulfilled(editor.applyUserActions(docId, [['AddRecord', 'Data', null, {}]]));
// Add anonymous user as an editor.
await owner.updateDocPermissions(docId, { users: { "anon@getgrist.com": 'editors' } });
const anonym = await openClient(home.server, "anon@getgrist.com", "testy");
anonym.ignoreTrivialActions();
await anonym.openDocOnConnect(docId);
try {
// Make sure he add record too
let result = await anonym.send('applyUserActions', 0, [['AddRecord', 'Data', null, {}]]);
assert.isUndefined(result.errorCode);
// Now make rule, that he can't using UserRef attribute.
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Data', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.UserRef is None', permissionsText: 'none',
}],
]);
// Test that ACL rules works as usual for logged in user.
await assert.isFulfilled(owner.applyUserActions(docId, [['AddRecord', 'Data', null, {}]]));
await assert.isFulfilled(editor.applyUserActions(docId, [['AddRecord', 'Data', null, {}]]));
// Test our new rule based on UserRef attribute.
result = await anonym.send('applyUserActions', 0, [['AddRecord', 'Data', null, {}]]);
assert.equal(result.errorCode, 'ACL_DENY');
} finally {
anonym.flush();
await closeClient(anonym);
}
});
it('cannot modify _grist_Attachments directly when granular access applies', async function() {
await freshDoc();
await owner.applyUserActions(docId, [
['AddTable', 'Data1', [{id: 'Pics', type: 'Attachments'}]],
['AddRecord', '_grist_ACLResources', -1, {tableId: '*', colIds: '*'}],
// Add a dummy rule that doesn't change anything, just to make sure that
// granular access rules are processed.
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.Access in [OWNER]', permissionsText: 'all',
}],
]);
// Add an attachment through regular mechanism.
const i1 = await owner.getDocAPI(docId).uploadAttachment('data1', '1.png');
await owner.getDocAPI(docId).addRows('Data1', {Pics: [[GristObjCode.List, i1]]});
// Try to modify _grist_Attachments by shady means.
await assert.isRejected(owner.getDocAPI(docId).addRows('_grist_Attachments', {fileName: ['A', 'B']}),
/_grist_Attachments modification is not allowed/);
await assert.isRejected(owner.getDocAPI(docId).updateRows('_grist_Attachments', {id: [1], fileName: ['A']}),
/_grist_Attachments modification is not allowed/);
await assert.isRejected(owner.getDocAPI(docId).removeRows('_grist_Attachments', [1]),
/_grist_Attachments modification is not allowed/);
});
describe('shares', function() {
it('can give table access for a form', async function() {
await freshDoc();
// Publish an empty share.
await owner.applyUserActions(docId, [
['AddRecord', '_grist_Shares', null, {
linkId: 'x',
options: '{"publish": true}'
}],
]);
// Check it reached the home db.
let shares = await home.dbManager.connection.query('select * from shares');
assert.lengthOf(shares, 1);
assert.equal(shares[0].link_id, 'x');
assert.deepEqual(JSON.parse(shares[0].options),
{ publish: true });
assert.equal(shares[0].id, 1);
assert.isAtLeast(shares[0].key.length, 12);
// Check that user data is not yet available via the share.
const ham = await home.createHomeApi('ham', 'docs', true);
const hamShare = ham.getDocAPI(await getShareKeyForUrl('x'));
await assert.isRejected(hamShare.getRows('Table1'), /Forbidden/);
// Check that metadata is available but censored.
let tables = await hamShare.getRows('_grist_Tables');
assert.lengthOf(tables.id, 1);
assert.equal(tables.tableId[0], '');
// Form-share a section.
await owner.applyUserActions(docId, [
['UpdateRecord', '_grist_Views_section', 1,
{shareOptions: '{"publish": true, "form": true}'}],
['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}],
['AddRecord', 'Table1', null, {A: 1, B: 1, C: 1}],
]);
// Check the appropriate table is now available.
tables = await hamShare.getRows('_grist_Tables');
assert.lengthOf(tables.id, 1);
assert.equal(tables.tableId[0], 'Table1');
// Check an empty read is possible. This is a
// convenience rather than a necessity.
assert.deepEqual(
await hamShare.getRows('Table1'),
{ id: [], manualSort: [], A: [], C: [], B: [] }
);
// Owner sees all rows.
assert.deepEqual(
await owner.getDocAPI(docId).getRows('Table1'),
{ id: [1], manualSort: [1], A: [1], C: [1], B: [1] }
);
// Creating a row should be allowed.
await hamShare.addRows('Table1', { A: [99] });
// Still don't see anything.
assert.deepEqual(
await hamShare.getRows('Table1'),
{ id: [], manualSort: [], A: [], C: [], B: [] }
);
// Confirm row is actually there.
assert.deepEqual(
await owner.getDocAPI(docId).getRows('Table1'),
{ id: [1, 2], manualSort: [1, 2], A: [1, 99], C: [1, 0], B: [1, 0] }
);
// Updates not allowed.
await assert.isRejected(hamShare.updateRows('Table1', { id: [2], A: [100] }), /Forbidden/);
// Removals not allowed.
await assert.isRejected(hamShare.removeRows('Table1', [2]), /Forbidden/);
// Check both operations work when you have rights.
await owner.getDocAPI(docId).updateRows('Table1', { id: [2], A: [100] });
await owner.getDocAPI(docId).removeRows('Table1', [2]);
// Modify shares options in doc, and see that they propagate.
await owner.applyUserActions(docId, [
['UpdateRecord', '_grist_Shares', 1, {
options: '{"publish": true, "test": true}'
}],
]);
shares = await home.dbManager.connection.query('select * from shares');
assert.lengthOf(shares, 1);
assert.deepEqual(JSON.parse(shares[0].options),
{publish: true, test: true});
// Unpublish at share level, and make sure data access
// is now forbidden.
await owner.applyUserActions(docId, [
['UpdateRecord', '_grist_Shares', 1, {
options: '{"publish": false}'
}],
]);
await assert.isRejected(hamShare.getRows('Table1'), /Forbidden/);
await assert.isRejected(hamShare.getRows('_grist_Tables'), /Forbidden/);
await owner.applyUserActions(docId, [
['RemoveRecord', '_grist_Shares', 1]
]);
shares = await home.dbManager.connection.query('select * from shares');
assert.lengthOf(shares, 0);
});
it('can give access to referenced columns for a form', async function() {
// Use a fixture, since references with display columns are
// awkward to set up via the api
await freshDoc('FilmsWithImages.grist');
// Publish an empty share.
await owner.applyUserActions(docId, [
['AddRecord', '_grist_Shares', null, {
linkId: 'x',
options: '{"publish": true}'
}],
]);
await owner.applyUserActions(docId, [
// Turn on sharing on Friends widget on Friends page.
['UpdateRecord', '_grist_Views_section', 7,
{shareOptions: '{"publish": true, "form": true}'}],
['UpdateRecord', '_grist_Pages', 2, {shareRef: 1}],
// Add some access rules too - there was a bug where references were
// null if a multi-column table rule was present.
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Films', colIds: 'Title,Poster,PosterDup'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: 'Films', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'user.access != OWNER', permissionsText: '-R',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'True', permissionsText: 'all',
}],
]);
const ham = await home.createHomeApi('ham', 'docs', true);
const hamDoc = ham.getDocAPI(docId);
const hamShare = ham.getDocAPI(await getShareKeyForUrl('x'));
// Friends looks empty.
assert.deepEqual(await hamShare.getRecords('Friends'), []);
await assert.isRejected(hamDoc.getRecords('Friends'), /Forbidden/);
// Films has just a title.
assert.deepEqual(await hamShare.getRecords('Films'), [
{ id: 1, fields: { Title: 'Toy Story' } },
{ id: 2, fields: { Title: 'Forrest Gump' } },
{ id: 3, fields: { Title: 'Alien' } },
{ id: 4, fields: { Title: 'Avatar' } },
{ id: 5, fields: { Title: 'The Dark Knight' } },
{ id: 6, fields: { Title: 'The Avengers' } }
]);
await assert.isRejected(hamDoc.getRecords('Films'), /Forbidden/);
// Performance is not involved.
await assert.isRejected(hamShare.getRecords('Performances'), /Forbidden/);
await assert.isRejected(hamDoc.getRecords('Performances'), /Forbidden/);
// Find "Favorite Film" field on single section of "Friends" view.
const field = (await owner.getDocAPI(docId).sql(
'select v.name, v.type, t.tableId, f.id, c.colId, s.title from _grist_Views_section_field as f' +
' left join _grist_Views_section s on s.id = f.parentId' +
' left join _grist_Tables_column c on c.id = f.colRef' +
' left join _grist_Tables t on t.id = c.parentId' +
' left join _grist_Views v on v.id = s.parentId' +
' where v.name = ? and c.colId = ? and s.title = ?',
[ 'Friends', 'Favorite_Film', '' ],
)).records[0].fields;
assert.equal(field.colId, 'Favorite_Film');
// Double check we can read film titles currently.
assert.deepEqual(await hamShare.getRecords('Films'), [
{ id: 1, fields: { Title: 'Toy Story' } },
{ id: 2, fields: { Title: 'Forrest Gump' } },
{ id: 3, fields: { Title: 'Alien' } },
{ id: 4, fields: { Title: 'Avatar' } },
{ id: 5, fields: { Title: 'The Dark Knight' } },
{ id: 6, fields: { Title: 'The Avengers' } }
]);
// Hide the field that refers to film titles.
await owner.applyUserActions(docId, [[
"RemoveRecord", "_grist_Views_section_field", field.id,
]]);
// Check we can no longer read film titles in the share.
await assert.isRejected(hamShare.getRecords('Films'), /Forbidden/);
await removeShares(docId, owner);
});
it('are separate from document access rules', async function() {
await freshDoc('FilmsWithImages.grist');
await owner.applyUserActions(docId, [
['AddRecord', '_grist_Shares', null, {
linkId: 'x',
options: '{"publish": true}'
}],
]);
await owner.applyUserActions(docId, [
['UpdateRecord', '_grist_Views_section', 7,
{shareOptions: '{"publish": true, "form": true}'}],
['UpdateRecord', '_grist_Pages', 2, {shareRef: 1}],
]);
const ham = await home.createHomeApi('ham', 'docs', true);
const hamDoc = ham.getDocAPI(docId);
const hamShare = ham.getDocAPI(await getShareKeyForUrl('x'));
const ownerDoc = owner.getDocAPI(docId);
// Check that neither share nor doc can update records.
await owner.applyUserActions(docId, [
['AddRecord', '_grist_ACLResources', -1, {tableId: 'Films', colIds: 'Title,Budget_millions'}],
['AddRecord', '_grist_ACLResources', -2, {tableId: '*', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: 'True', permissionsText: '+R',
}],
['AddRecord', '_grist_ACLRules', null, {
resource: -2, aclFormula: 'True', permissionsText: '-U',
}],
]);
assert.deepEqual(await ownerDoc.getRows('_grist_ACLRules'), {
id: [1, 2, 3],
aclColumn: [0, 0, 0],
resource: [1, 2, 3],
memo: ['', '', ''],
aclFormula: ['', 'True', 'True'],
userAttributes: ['', '', ''],
aclFormulaParsed: ['', '["Const", true]', '["Const", true]'],
permissionsText: ['', '+R', '-U'],
rulePos: [null, 1, 2],
principals: ['[1]', '', ''],
permissions: [63, 0, 0],
});
await assertDeniedFor(hamDoc.updateRows('Films', {id: [1], Title: ['Toy Story 2']}), [], /No view access/);
await assertDeniedFor(hamShare.updateRows('Films', {id: [1], Title: ['Toy Story 2']}), []);
await assertDeniedFor(ownerDoc.updateRows('Films', {id: [1], Title: ['Toy Story 2']}), [],
/Blocked by table update access rules/);
// Grant update permission to doc. Check that the share still can't update records.
await owner.applyUserActions(docId, [
['UpdateRecord', '_grist_ACLRules', 3, {permissionsText: '+U'}],
]);
assert.deepEqual(await ownerDoc.getRows('_grist_ACLRules'), {
id: [1, 2, 3],
aclColumn: [0, 0, 0],
resource: [1, 2, 3],
memo: ['', '', ''],
aclFormula: ['', 'True', 'True'],
userAttributes: ['', '', ''],
aclFormulaParsed: ['', '["Const", true]', '["Const", true]'],
permissionsText: ['', '+R', '+U'],
rulePos: [null, 1, 2],
principals: ['[1]', '', ''],
permissions: [63, 0, 0],
});
await assert.isRejected(hamDoc.updateRows('Films', {id: [1], Title: ['Toy Story 2']}), /Forbidden/);
await assertDeniedFor(hamShare.updateRows('Films', {id: [1], Title: ['Toy Story 2']}), []);
await assert.isFulfilled(ownerDoc.updateRows('Films', {id: [1], Title: ['Toy Story 2']}));
await removeShares(docId, owner);
});
it('can give access to a pair of form-shared widgets on same page', async function() {
await freshDoc('ManyRefs.grist');
// Publish an empty share.
await owner.applyUserActions(docId, [
['AddRecord', '_grist_Shares', null, {
linkId: 'manyref',
options: '{"publish": true}'
}],
]);
// viewsections 19 and 20, parent view 7, page 7.
await owner.applyUserActions(docId, [
// Turn on sharing on "Dashboard" page
['UpdateRecord', '_grist_Pages', 7, {shareRef: 1}],
// Turn on form-sharing on "FILM" section
['UpdateRecord', '_grist_Views_section', 19,
{shareOptions: '{"publish": true, "form": true}'}],
// Turn on form-sharing on "CUSTOMER" section
['UpdateRecord', '_grist_Views_section', 20,
{shareOptions: '{"publish": true, "form": true}'}],
]);
const ham = await home.createHomeApi('kiwi', 'docs', true);
const hamShare = ham.getDocAPI(await getShareKeyForUrl('manyref'));
// Friends looks empty - we just have rights to add records.
// assert.deepEqual(await anonDoc.getRecords('Film'), []);
// Can read some Actor columns, Codes for a Ref in one section,
// and Name for a RefList in another section.
// Some material is readable from Actor table for a reference
// and a ref list.
assert.deepEqual(await hamShare.getRecords('Actor'), [
{ id: 1, fields: { Code: 'ACT101', Name: 'Impressive Name' } },
{ id: 2, fields: { Code: 'ACT102', Name: 'Implausible Name' } }
]);
// No content readable from Films, but the read is allowed
// (a bit of a hack to allow form-like submissions via
// regular web client).
assert.deepEqual(await hamShare.getRecords('Film'), []);
// Customer is a bit complicated. Reads allowed, but mostly
// no content available - EXCEPT for a column referenced by
// another shared widget.
const censored: any = [ 'C' ];
assert.deepEqual(await hamShare.getRecords('Customer'), [
{
id: 1,
fields: {
Name: "J Public",
Year_Joined: censored,
Good_Customer: censored,
Fav_Actor_Code: censored,
}
},
{
id: 2,
fields: {
Name: "K Public",
Year_Joined: censored,
Good_Customer: censored,
Fav_Actor_Code: censored,
}
}
]);
// Make sure that basic functionality of adding rows works,
// for the expected tables.
await hamShare.addRows('Film', { Name: ['Foo'] });
await hamShare.addRows('Customer', { Name: ['Foo'] });
await assert.isRejected(hamShare.addRows('Actor', { Name: ['Foo'] }));
await removeShares(docId, owner);
const shares = await home.dbManager.connection.query('select * from shares');
assert.lengthOf(shares, 0);
});
it('can use shares after a copy', async function() {
await freshDoc();
// Publish an empty share.
await owner.applyUserActions(docId, [
['AddRecord', '_grist_Shares', null, {
linkId: 'x2',
options: '{"publish": true}'
}],
]);
// Check it reached the home db.
let shares = await home.dbManager.connection.query('select * from shares');
assert.lengthOf(shares, 1);
assert.equal(shares[0].link_id, 'x2');
const copyDocId = await owner.copyDoc(docId, wsId, {
documentName: 'copy',
});
// Do anything with the new document.
await owner.getDocAPI(copyDocId).getRows('Table1');
shares = await home.dbManager.connection.query('select * from shares');
assert.lengthOf(shares, 2);
assert.equal(shares[0].link_id, 'x2');
assert.equal(shares[1].link_id, 'x2');
assert.notEqual(shares[0].doc_id, shares[1].doc_id);
await removeShares(docId, owner);
await removeShares(copyDocId, owner);
});
});
});
async function closeClient(cli: GristClient) {
if (cli.ws.isOpen()) {
await cli.send("closeDoc", 0);
}
await cli.close();
}
// Create a wrapper to check that some property doesn't change during a test.
function assertUnchanged(check: () => PromiseLike<any>) {
return async (body: PromiseLike<any>) => {
const pre = await check();
await body;
const post = await check();
assert.deepEqual(pre, post);
};
}
async function assertDeniedFor(check: Promise<any>, memos: string[], test = /access rules/) {
try {
await check;
throw new Error('not denied');
} catch (e) {
assert.match(e?.details?.userError, test);
assert.deepEqual(e?.details?.memos ?? [], memos);
}
}
// Read the content of an attachment, as text.
async function getAttachment(api: UserAPI, docId: string, attId: number) {
const userApi = api as UserAPIImpl;
const result = await userApi.testRequest(
userApi.getBaseUrl() + `/api/docs/${docId}/attachments/${attId}/download`, {
headers: userApi.defaultHeadersWithoutContentType()
}
);
return result.text();
}
async function assertFlux(check: Promise<any>) {
try {
await check;
throw new Error('not denied');
} catch (e) {
assert.match(e?.details?.userError, /Document in flux/);
}
}