gristlabs_grist-core/test/server/lib/Authorizer.ts
Dmitry S 3210eee24f (core) Revamp ForwardAuthLogin and unify with GRIST_PROXY_AUTH_HEADER
Summary:
By default, only respect GRIST_FORWARD_AUTH_HEADER on login endpoints; sessions are used elsewhere.

With GRIST_IGNORE_SESSION, do not use sessions, and respect GRIST_FORWARD_AUTH_HEADER on all endpoints.

GRIST_PROXY_AUTH_HEADER is now a synonym to GRIST_FORWARD_AUTH_HEADER.

Test Plan: Fixed tests. Tested first approach (no GRIST_IGNORE_SESSION) with grist-omnibus manually. Tested the second approach (with GRIST_IGNORE_SESSION) with a Apache-based setup enforcing http basic auth on all endpoints.

Reviewers: paulfitz, georgegevoian

Reviewed By: paulfitz, georgegevoian

Differential Revision: https://phab.getgrist.com/D4104
2023-11-07 16:30:49 -05:00

320 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 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.addTelemetry();
await home.addDoc();
home.addApiErrorHandlers();
home.finalizeEndpoints();
await home.finalizePlugins(null);
home.ready();
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');
const docTools = createDocTools({persistAcrossCases: true, useFixturePlugins: false,
server: () => (server = new FlexServer(0, 'test docWorker'))});
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();
// GRIST_PROXY_AUTH_HEADER now only affects requests directly when GRIST_IGNORE_SESSION is
// also set.
process.env.GRIST_PROXY_AUTH_HEADER = 'X-email';
process.env.GRIST_IGNORE_SESSION = 'true';
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(withoutTimestamp(resp.data),
withoutTimestamp(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}]]);
// Skip messages with no actions (since docUsage may or may not appear by now)
const messagesWithActions = cli.messages.filter(m => m.data.docActions);
assert.lengthOf(messagesWithActions, 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();
});
});
function withoutTimestamp(txt: string): string {
return txt.replace(/"timestampMs":[ 0-9]+/, '"timestampMs": NNNN');
}