gristlabs_grist-core/test/server/lib/GranularAccess.ts
Paul Fitzpatrick fc3a7f580c
make access control for ConvertFromColumn action less brutal (#1111)
Access control for ConvertFromColumn in the presence of access rules had previously been left as a TODO. This change allows the action when the user has schema rights. Because schema rights let you create formulas, they let you read anything, so there is currently no value in nuance here.
2024-07-24 11:41:50 -04:00

4138 lines
184 KiB
TypeScript

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/);
}
}