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