mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
561d9696aa
Summary: For self-hosted Grist, forward auth has proven useful, where some proxy wrapped around Grist manages authentication, and passes on user information to Grist in a trusted header. The current implementation is adequate when Grist is the only place where the user logs in or out, but is confusing otherwise (see https://github.com/gristlabs/grist-core/issues/207). Here we take some steps to broaden the scenarios Grist's forward auth support can be used with: * When a trusted header is present and is blank, treat that as the user not being logged in, and don't look any further for identity information. Specifically, don't look in Grist's session information. * Add a `GRIST_IGNORE_SESSION` flag to entirely prevent Grist from picking up identity information from a cookie, in order to avoid confusion between multiple login methods. * Add tests for common scenarios. Test Plan: added tests Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3482
307 lines
13 KiB
TypeScript
307 lines
13 KiB
TypeScript
import {parseUrlId} from 'app/common/gristUrls';
|
|
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
|
import {DocManager} from 'app/server/lib/DocManager';
|
|
import {FlexServer} from 'app/server/lib/FlexServer';
|
|
import axios from 'axios';
|
|
import {assert} from 'chai';
|
|
import {toPairs} from 'lodash';
|
|
import {createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed';
|
|
import {configForUser, getGristConfig} from 'test/gen-server/testUtils';
|
|
import {createDocTools} from 'test/server/docTools';
|
|
import {openClient} from 'test/server/gristClient';
|
|
import * as testUtils from 'test/server/testUtils';
|
|
import * as uuidv4 from 'uuid/v4';
|
|
|
|
let serverUrl: string;
|
|
let server: FlexServer;
|
|
let dbManager: HomeDBManager;
|
|
|
|
async function activateServer(home: FlexServer, docManager: DocManager) {
|
|
await home.loadConfig();
|
|
await home.initHomeDBManager();
|
|
home.addHosts();
|
|
home.addDocWorkerMap();
|
|
home.addAccessMiddleware();
|
|
dbManager = home.getHomeDBManager();
|
|
await home.loadConfig();
|
|
home.addSessions();
|
|
home.addHealthCheck();
|
|
docManager.testSetHomeDbManager(dbManager);
|
|
home.testSetDocManager(docManager);
|
|
await home.start();
|
|
home.addAccessMiddleware();
|
|
home.addApiMiddleware();
|
|
home.addJsonSupport();
|
|
await home.addLandingPages();
|
|
home.addHomeApi();
|
|
await home.addDoc();
|
|
home.addApiErrorHandlers();
|
|
serverUrl = home.getOwnUrl();
|
|
}
|
|
|
|
const chimpy = configForUser('Chimpy');
|
|
const charon = configForUser('Charon');
|
|
|
|
const fixtures: {[docName: string]: string|null} = {
|
|
Bananas: 'Hello.grist',
|
|
Pluto: 'Hello.grist',
|
|
};
|
|
|
|
describe('Authorizer', function() {
|
|
|
|
testUtils.setTmpLogLevel('fatal');
|
|
|
|
server = new FlexServer(0, 'test docWorker');
|
|
const docTools = createDocTools({persistAcrossCases: true, useFixturePlugins: false,
|
|
server});
|
|
const docs: {[name: string]: {id: string}} = {};
|
|
|
|
// Loads the fixtures documents so that they are available to the doc worker under the correct
|
|
// names.
|
|
async function loadFixtureDocs() {
|
|
for (const [docName, fixtureDoc] of toPairs(fixtures)) {
|
|
const docId = String(await dbManager.testGetId(docName));
|
|
if (fixtureDoc) {
|
|
await docTools.loadFixtureDocAs(fixtureDoc, docId);
|
|
} else {
|
|
await docTools.createDoc(docId);
|
|
}
|
|
docs[docName] = {id: docId};
|
|
}
|
|
}
|
|
|
|
let oldEnv: testUtils.EnvironmentSnapshot;
|
|
before(async function() {
|
|
this.timeout(5000);
|
|
setUpDB(this);
|
|
oldEnv = new testUtils.EnvironmentSnapshot();
|
|
process.env.GRIST_PROXY_AUTH_HEADER = 'X-email';
|
|
await createInitialDb();
|
|
await activateServer(server, docTools.getDocManager());
|
|
await loadFixtureDocs();
|
|
});
|
|
|
|
after(async function() {
|
|
const messages = await testUtils.captureLog('warn', async () => {
|
|
await server.close();
|
|
await removeConnection();
|
|
});
|
|
assert.lengthOf(messages, 0);
|
|
oldEnv.restore();
|
|
});
|
|
|
|
// TODO XXX Is it safe to remove this support now?
|
|
// (It used to be implemented in getDocAccessInfo() in Authorizer.ts).
|
|
it.skip("viewer gets redirect by title", async function() {
|
|
const resp = await axios.get(`${serverUrl}/o/pr/doc/Bananas`, chimpy);
|
|
assert.equal(resp.status, 200);
|
|
assert.equal(getGristConfig(resp.data).assignmentId, 'sample_6');
|
|
assert.match(resp.request.res.responseUrl, /\/doc\/sample_6$/);
|
|
const resp2 = await axios.get(`${serverUrl}/o/nasa/doc/Pluto`, chimpy);
|
|
assert.equal(resp2.status, 200);
|
|
assert.equal(getGristConfig(resp2.data).assignmentId, 'sample_2');
|
|
assert.match(resp2.request.res.responseUrl, /\/doc\/sample_2$/);
|
|
});
|
|
|
|
it("stranger gets consistent refusal regardless of title", async function() {
|
|
const resp = await axios.get(`${serverUrl}/o/pr/doc/Bananas`, charon);
|
|
assert.equal(resp.status, 404);
|
|
assert.notMatch(resp.data, /sample_6/);
|
|
const resp2 = await axios.get(`${serverUrl}/o/pr/doc/Bananas2`, charon);
|
|
assert.equal(resp2.status, 404);
|
|
assert.notMatch(resp.data, /sample_6/);
|
|
assert.deepEqual(resp.data, resp2.data);
|
|
});
|
|
|
|
it("viewer can access title", async function() {
|
|
const resp = await axios.get(`${serverUrl}/o/pr/doc/sample_6`, chimpy);
|
|
assert.equal(resp.status, 200);
|
|
const config = getGristConfig(resp.data);
|
|
assert.equal(config.getDoc![config.assignmentId!].name, 'Bananas');
|
|
});
|
|
|
|
it("stranger cannot access title", async function() {
|
|
const resp = await axios.get(`${serverUrl}/o/pr/doc/sample_6`, charon);
|
|
assert.equal(resp.status, 403);
|
|
assert.notMatch(resp.data, /Bananas/);
|
|
});
|
|
|
|
it("viewer cannot access document from wrong org", async function() {
|
|
const resp = await axios.get(`${serverUrl}/o/nasa/doc/sample_6`, chimpy);
|
|
assert.equal(resp.status, 404);
|
|
});
|
|
|
|
it("websocket allows openDoc for viewer", async function() {
|
|
const cli = await openClient(server, 'chimpy@getgrist.com', 'pr');
|
|
cli.ignoreTrivialActions();
|
|
assert.equal((await cli.readMessage()).type, 'clientConnect');
|
|
const openDoc = await cli.send("openDoc", "sample_6");
|
|
assert.equal(openDoc.error, undefined);
|
|
assert.match(JSON.stringify(openDoc.data), /Table1/);
|
|
await cli.close();
|
|
});
|
|
|
|
it("websocket forbids openDoc for stranger", async function() {
|
|
const cli = await openClient(server, 'charon@getgrist.com', 'pr');
|
|
cli.ignoreTrivialActions();
|
|
assert.equal((await cli.readMessage()).type, 'clientConnect');
|
|
const openDoc = await cli.send("openDoc", "sample_6");
|
|
assert.match(openDoc.error!, /No view access/);
|
|
assert.equal(openDoc.data, undefined);
|
|
assert.match(openDoc.errorCode!, /AUTH_NO_VIEW/);
|
|
await cli.close();
|
|
});
|
|
|
|
it("websocket forbids applyUserActions for viewer", async function() {
|
|
const cli = await openClient(server, 'charon@getgrist.com', 'nasa');
|
|
cli.ignoreTrivialActions();
|
|
assert.equal((await cli.readMessage()).type, 'clientConnect');
|
|
const openDoc = await cli.openDocOnConnect("sample_2");
|
|
assert.equal(openDoc.error, undefined);
|
|
const nonce = uuidv4();
|
|
const applyUserActions = await cli.send("applyUserActions",
|
|
0,
|
|
[["UpdateRecord", "Table1", 1, {A: nonce}], {}]);
|
|
assert.lengthOf(cli.messages, 0); // no user actions pushed to client
|
|
assert.match(applyUserActions.error!, /No write access/);
|
|
assert.match(applyUserActions.errorCode!, /AUTH_NO_EDIT/);
|
|
const fetchTable = await cli.send("fetchTable", 0, "Table1");
|
|
assert.equal(fetchTable.error, undefined);
|
|
assert.notInclude(JSON.stringify(fetchTable.data), nonce);
|
|
await cli.close();
|
|
});
|
|
|
|
it("websocket allows applyUserActions for editor", async function() {
|
|
const cli = await openClient(server, 'chimpy@getgrist.com', 'nasa');
|
|
cli.ignoreTrivialActions();
|
|
assert.equal((await cli.readMessage()).type, 'clientConnect');
|
|
const openDoc = await cli.openDocOnConnect("sample_2");
|
|
assert.equal(openDoc.error, undefined);
|
|
const nonce = uuidv4();
|
|
const applyUserActions = await cli.send("applyUserActions",
|
|
0,
|
|
[["UpdateRecord", "Table1", 1, {A: nonce}]]);
|
|
assert.lengthOf(cli.messages, 1); // user actions pushed to client
|
|
assert.equal(applyUserActions.error, undefined);
|
|
const fetchTable = await cli.send("fetchTable", 0, "Table1");
|
|
assert.equal(fetchTable.error, undefined);
|
|
assert.include(JSON.stringify(fetchTable.data), nonce);
|
|
await cli.close();
|
|
});
|
|
|
|
it("can keep different simultaneous clients of a doc straight", async function() {
|
|
const editor = await openClient(server, 'chimpy@getgrist.com', 'nasa');
|
|
assert.equal((await editor.readMessage()).type, 'clientConnect');
|
|
const viewer = await openClient(server, 'charon@getgrist.com', 'nasa');
|
|
assert.equal((await viewer.readMessage()).type, 'clientConnect');
|
|
const stranger = await openClient(server, 'kiwi@getgrist.com', 'nasa');
|
|
assert.equal((await stranger.readMessage()).type, 'clientConnect');
|
|
|
|
editor.ignoreTrivialActions();
|
|
viewer.ignoreTrivialActions();
|
|
stranger.ignoreTrivialActions();
|
|
assert.equal((await editor.send("openDoc", "sample_2")).error, undefined);
|
|
assert.equal((await viewer.send("openDoc", "sample_2")).error, undefined);
|
|
assert.match((await stranger.send("openDoc", "sample_2")).error!, /No view access/);
|
|
|
|
const action = [0, [["UpdateRecord", "Table1", 1, {A: "foo"}]]];
|
|
assert.equal((await editor.send("applyUserActions", ...action)).error, undefined);
|
|
assert.match((await viewer.send("applyUserActions", ...action)).error!, /No write access/);
|
|
// Different message here because sending actions without a doc being open.
|
|
assert.match((await stranger.send("applyUserActions", ...action)).error!, /Invalid/);
|
|
});
|
|
|
|
it("previewer has view access to docs", async function() {
|
|
const cli = await openClient(server, 'thumbnail@getgrist.com', 'nasa');
|
|
cli.ignoreTrivialActions();
|
|
assert.equal((await cli.readMessage()).type, 'clientConnect');
|
|
const openDoc = await cli.send("openDoc", "sample_2");
|
|
assert.equal(openDoc.error, undefined);
|
|
const nonce = uuidv4();
|
|
const applyUserActions = await cli.send("applyUserActions",
|
|
0,
|
|
[["UpdateRecord", "Table1", 1, {A: nonce}], {}]);
|
|
assert.lengthOf(cli.messages, 0); // no user actions pushed to client
|
|
assert.match(applyUserActions.error!, /No write access/);
|
|
assert.match(applyUserActions.errorCode!, /AUTH_NO_EDIT/);
|
|
const fetchTable = await cli.send("fetchTable", 0, "Table1");
|
|
assert.equal(fetchTable.error, undefined);
|
|
assert.notInclude(JSON.stringify(fetchTable.data), nonce);
|
|
await cli.close();
|
|
});
|
|
|
|
it("viewer can fork doc", async function() {
|
|
const cli = await openClient(server, 'charon@getgrist.com', 'nasa');
|
|
cli.ignoreTrivialActions();
|
|
assert.equal((await cli.readMessage()).type, 'clientConnect');
|
|
const openDoc = await cli.send("openDoc", "sample_2");
|
|
assert.equal(openDoc.error, undefined);
|
|
const result = await cli.send("fork", 0);
|
|
assert.equal(result.data.docId, result.data.urlId);
|
|
const parts = parseUrlId(result.data.docId);
|
|
assert.equal(parts.trunkId, "sample_2");
|
|
assert.isAbove(parts.forkId!.length, 4);
|
|
assert.equal(parts.forkUserId, await dbManager.testGetId('Charon') as number);
|
|
});
|
|
|
|
it("anon can fork doc", async function() {
|
|
// anon does not have access to doc initially
|
|
const cli = await openClient(server, 'anon@getgrist.com', 'nasa');
|
|
cli.ignoreTrivialActions();
|
|
assert.equal((await cli.readMessage()).type, 'clientConnect');
|
|
let openDoc = await cli.send("openDoc", "sample_2");
|
|
assert.match(openDoc.error!, /No view access/);
|
|
|
|
// grant anon access to doc and retry
|
|
await dbManager.updateDocPermissions({
|
|
userId: await dbManager.testGetId('Chimpy') as number,
|
|
urlId: 'sample_2',
|
|
org: 'nasa'
|
|
}, {users: {"anon@getgrist.com": "viewers"}});
|
|
dbManager.flushDocAuthCache();
|
|
openDoc = await cli.send("openDoc", "sample_2");
|
|
assert.equal(openDoc.error, undefined);
|
|
|
|
// make a fork
|
|
const result = await cli.send("fork", 0);
|
|
assert.equal(result.data.docId, result.data.urlId);
|
|
const parts = parseUrlId(result.data.docId);
|
|
assert.equal(parts.trunkId, "sample_2");
|
|
assert.isAbove(parts.forkId!.length, 4);
|
|
assert.equal(parts.forkUserId, undefined);
|
|
});
|
|
|
|
it("can set user via GRIST_PROXY_AUTH_HEADER", async function() {
|
|
// User can access a doc by setting header.
|
|
const docUrl = `${serverUrl}/o/pr/api/docs/sample_6`;
|
|
const resp = await axios.get(docUrl, {
|
|
headers: {'X-email': 'chimpy@getgrist.com'}
|
|
});
|
|
assert.equal(resp.data.name, 'Bananas');
|
|
|
|
// Unknown user is denied.
|
|
await assert.isRejected(axios.get(docUrl, {
|
|
headers: {'X-email': 'notchimpy@getgrist.com'}
|
|
}));
|
|
|
|
// User can access a doc via websocket by setting header.
|
|
let cli = await openClient(server, 'chimpy@getgrist.com', 'pr', 'X-email');
|
|
cli.ignoreTrivialActions();
|
|
assert.equal((await cli.readMessage()).type, 'clientConnect');
|
|
let openDoc = await cli.send("openDoc", "sample_6");
|
|
assert.equal(openDoc.error, undefined);
|
|
assert.match(JSON.stringify(openDoc.data), /Table1/);
|
|
await cli.close();
|
|
|
|
// Unknown user is denied.
|
|
cli = await openClient(server, 'notchimpy@getgrist.com', 'pr', 'X-email');
|
|
cli.ignoreTrivialActions();
|
|
assert.equal((await cli.readMessage()).type, 'clientConnect');
|
|
openDoc = await cli.send("openDoc", "sample_6");
|
|
assert.match(openDoc.error!, /No view access/);
|
|
assert.equal(openDoc.data, undefined);
|
|
assert.match(openDoc.errorCode!, /AUTH_NO_VIEW/);
|
|
await cli.close();
|
|
});
|
|
});
|