diff --git a/test/server/lib/GranularAccess.ts b/test/server/lib/GranularAccess.ts new file mode 100644 index 00000000..1748df21 --- /dev/null +++ b/test/server/lib/GranularAccess.ts @@ -0,0 +1,4085 @@ +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'}] + ]); + }); + + 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', {}], + ]), /need uncomplicated access/); + + await assert.isRejected(editor.applyUserActions(docId, [ + ['RenameColumn', 'Data1', 'B', 'B'], + ['CopyFromColumn', 'Data1', 'A', 'B', {}], + ]), /need uncomplicated access/); + + 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/); + } +}