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 { 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) .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; 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) { return async (body: PromiseLike) => { const pre = await check(); await body; const post = await check(); assert.deepEqual(pre, post); }; } async function assertDeniedFor(check: Promise, 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) { try { await check; throw new Error('not denied'); } catch (e) { assert.match(e?.details?.userError, /Document in flux/); } }