mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
add an endpoint for doing SQL selects (#641)
* add an endpoint for doing SQL selects This adds an endpoint for doing SQL selects directly on a Grist document. Other kinds of statements are not supported. There is a default timeout of a second on queries. This follows loosely an API design by Alex Hall. Co-authored-by: jarek <jaroslaw.sadzinski@gmail.com>
This commit is contained in:
parent
b465e07bb4
commit
bfd0fa8c7f
@ -61,6 +61,7 @@ export class DocApiForwarder {
|
|||||||
app.use('/api/docs/:docId/webhooks/queue', withDoc);
|
app.use('/api/docs/:docId/webhooks/queue', withDoc);
|
||||||
app.use('/api/docs/:docId/webhooks', withDoc);
|
app.use('/api/docs/:docId/webhooks', withDoc);
|
||||||
app.use('/api/docs/:docId/assistant', withDoc);
|
app.use('/api/docs/:docId/assistant', withDoc);
|
||||||
|
app.use('/api/docs/:docId/sql', withDoc);
|
||||||
app.use('^/api/docs$', withoutDoc);
|
app.use('^/api/docs$', withoutDoc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,6 +84,12 @@ export const TablesPatch = t.iface([], {
|
|||||||
"tables": t.tuple("RecordWithStringId", t.rest(t.array("RecordWithStringId"))),
|
"tables": t.tuple("RecordWithStringId", t.rest(t.array("RecordWithStringId"))),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const SqlPost = t.iface([], {
|
||||||
|
"sql": "string",
|
||||||
|
"args": t.opt(t.array("any")),
|
||||||
|
"timeout": t.opt("number"),
|
||||||
|
});
|
||||||
|
|
||||||
const exportedTypeSuite: t.ITypeSuite = {
|
const exportedTypeSuite: t.ITypeSuite = {
|
||||||
NewRecord,
|
NewRecord,
|
||||||
NewRecordWithStringId,
|
NewRecordWithStringId,
|
||||||
@ -101,5 +107,6 @@ const exportedTypeSuite: t.ITypeSuite = {
|
|||||||
TablePost,
|
TablePost,
|
||||||
TablesPost,
|
TablesPost,
|
||||||
TablesPatch,
|
TablesPatch,
|
||||||
|
SqlPost,
|
||||||
};
|
};
|
||||||
export default exportedTypeSuite;
|
export default exportedTypeSuite;
|
||||||
|
@ -107,3 +107,16 @@ export interface TablesPost {
|
|||||||
export interface TablesPatch {
|
export interface TablesPatch {
|
||||||
tables: [RecordWithStringId, ...RecordWithStringId[]]; // at least one table is required
|
tables: [RecordWithStringId, ...RecordWithStringId[]]; // at least one table is required
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON schema for the body of api /sql POST endpoint
|
||||||
|
*/
|
||||||
|
export interface SqlPost {
|
||||||
|
sql: string;
|
||||||
|
args?: any[]; // (It would be nice to support named parameters, but
|
||||||
|
// that feels tricky currently with node-sqlite3)
|
||||||
|
timeout?: number; // In msecs. Can only be reduced from server default,
|
||||||
|
// not increased. Note timeout of a query could affect
|
||||||
|
// other queued queries on same document, because of
|
||||||
|
// limitations of API node-sqlite3 exposes.
|
||||||
|
}
|
||||||
|
@ -914,6 +914,11 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
return this._granularAccess.canCopyEverything(docSession);
|
return this._granularAccess.canCopyEverything(docSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user has rights to read everything in this doc.
|
||||||
|
public async canCopyEverything(docSession: OptDocSession) {
|
||||||
|
return this._granularAccess.canCopyEverything(docSession);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if it is appropriate for the user to be treated as an owner of
|
// Check if it is appropriate for the user to be treated as an owner of
|
||||||
// the document for granular access purposes when in "prefork" mode
|
// the document for granular access purposes when in "prefork" mode
|
||||||
// (meaning a document has been opened with the intent to fork it, but
|
// (meaning a document has been opened with the intent to fork it, but
|
||||||
|
@ -96,6 +96,17 @@ export class AppSettings {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As for readInt() but fail if nothing was found.
|
||||||
|
*/
|
||||||
|
public requireInt(query: AppSettingQuery): number {
|
||||||
|
const result = this.readInt(query);
|
||||||
|
if (result === undefined) {
|
||||||
|
throw new Error(`missing environment variable: ${query.envVar}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* As for read() but type (and store, and report) the result as
|
* As for read() but type (and store, and report) the result as
|
||||||
* a boolean.
|
* a boolean.
|
||||||
@ -107,6 +118,17 @@ export class AppSettings {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As for read() but type (and store, and report) the result as
|
||||||
|
* an integer (well, a number).
|
||||||
|
*/
|
||||||
|
public readInt(query: AppSettingQuery): number|undefined {
|
||||||
|
this.readString(query);
|
||||||
|
const result = this.getAsInt();
|
||||||
|
this._value = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/* set this setting 'manually' */
|
/* set this setting 'manually' */
|
||||||
public set(value: JSONValue): void {
|
public set(value: JSONValue): void {
|
||||||
this._value = value;
|
this._value = value;
|
||||||
|
@ -32,6 +32,7 @@ import {
|
|||||||
TableOperationsPlatform
|
TableOperationsPlatform
|
||||||
} from 'app/plugin/TableOperationsImpl';
|
} from 'app/plugin/TableOperationsImpl';
|
||||||
import {ActiveDoc, colIdToRef as colIdToReference, tableIdToRef} from "app/server/lib/ActiveDoc";
|
import {ActiveDoc, colIdToRef as colIdToReference, tableIdToRef} from "app/server/lib/ActiveDoc";
|
||||||
|
import {appSettings} from "app/server/lib/AppSettings";
|
||||||
import {sendForCompletion} from 'app/server/lib/Assistance';
|
import {sendForCompletion} from 'app/server/lib/Assistance';
|
||||||
import {
|
import {
|
||||||
assertAccess,
|
assertAccess,
|
||||||
@ -96,16 +97,25 @@ const MAX_PARALLEL_REQUESTS_PER_DOC = 10;
|
|||||||
// then the _dailyUsage cache may become unreliable and users may be able to exceed their allocated requests.
|
// then the _dailyUsage cache may become unreliable and users may be able to exceed their allocated requests.
|
||||||
const MAX_ACTIVE_DOCS_USAGE_CACHE = 1000;
|
const MAX_ACTIVE_DOCS_USAGE_CACHE = 1000;
|
||||||
|
|
||||||
|
// Maximum duration of a call to /sql. Does not apply to internal calls to SQLite.
|
||||||
|
const MAX_CUSTOM_SQL_MSEC = appSettings.section('integrations')
|
||||||
|
.section('sql').flag('timeout').requireInt({
|
||||||
|
envVar: 'GRIST_SQL_TIMEOUT_MSEC',
|
||||||
|
defaultValue: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
type WithDocHandler = (activeDoc: ActiveDoc, req: RequestWithLogin, resp: Response) => Promise<void>;
|
type WithDocHandler = (activeDoc: ActiveDoc, req: RequestWithLogin, resp: Response) => Promise<void>;
|
||||||
|
|
||||||
// Schema validators for api endpoints that creates or updates records.
|
// Schema validators for api endpoints that creates or updates records.
|
||||||
const {
|
const {
|
||||||
RecordsPatch, RecordsPost, RecordsPut,
|
RecordsPatch, RecordsPost, RecordsPut,
|
||||||
ColumnsPost, ColumnsPatch, ColumnsPut,
|
ColumnsPost, ColumnsPatch, ColumnsPut,
|
||||||
|
SqlPost,
|
||||||
TablesPost, TablesPatch,
|
TablesPost, TablesPatch,
|
||||||
} = t.createCheckers(DocApiTypesTI, GristDataTI);
|
} = t.createCheckers(DocApiTypesTI, GristDataTI);
|
||||||
|
|
||||||
for (const checker of [RecordsPatch, RecordsPost, RecordsPut, ColumnsPost, ColumnsPatch, TablesPost, TablesPatch]) {
|
for (const checker of [RecordsPatch, RecordsPost, RecordsPut, ColumnsPost, ColumnsPatch,
|
||||||
|
SqlPost, TablesPost, TablesPatch]) {
|
||||||
checker.setReportedPath("body");
|
checker.setReportedPath("body");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -518,6 +528,27 @@ export class DocWorkerApi {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A GET /sql endpoint that takes a query like ?q=select+*+from+Table1
|
||||||
|
// Not very useful, apart from testing - see the POST endpoint for
|
||||||
|
// serious use.
|
||||||
|
// If SQL statements that modify the DB are ever supported, they should
|
||||||
|
// not be permitted by this endpoint.
|
||||||
|
this._app.get(
|
||||||
|
'/api/docs/:docId/sql', canView,
|
||||||
|
withDoc(async (activeDoc, req, res) => {
|
||||||
|
const sql = stringParam(req.query.q, 'q');
|
||||||
|
await this._runSql(activeDoc, req, res, { sql });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// A POST /sql endpoint, accepting a body like:
|
||||||
|
// { "sql": "select * from Table1 where name = ?", "args": ["Paul"] }
|
||||||
|
// Only SELECT statements are currently supported.
|
||||||
|
this._app.post(
|
||||||
|
'/api/docs/:docId/sql', canView, validate(SqlPost),
|
||||||
|
withDoc(async (activeDoc, req, res) => {
|
||||||
|
await this._runSql(activeDoc, req, res, req.body);
|
||||||
|
}));
|
||||||
|
|
||||||
// Create columns in a table, given as records of the _grist_Tables_column metatable.
|
// Create columns in a table, given as records of the _grist_Tables_column metatable.
|
||||||
this._app.post('/api/docs/:docId/tables/:tableId/columns', canEdit, validate(ColumnsPost),
|
this._app.post('/api/docs/:docId/tables/:tableId/columns', canEdit, validate(ColumnsPost),
|
||||||
withDoc(async (activeDoc, req, res) => {
|
withDoc(async (activeDoc, req, res) => {
|
||||||
@ -1502,6 +1533,73 @@ export class DocWorkerApi {
|
|||||||
await this._dbManager.flushSingleDocAuthCache(scope, docId);
|
await this._dbManager.flushSingleDocAuthCache(scope, docId);
|
||||||
await this._docManager.interruptDocClients(docId);
|
await this._docManager.interruptDocClients(docId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _runSql(activeDoc: ActiveDoc, req: RequestWithLogin, res: Response,
|
||||||
|
options: Types.SqlPost) {
|
||||||
|
if (!await activeDoc.canCopyEverything(docSessionFromRequest(req))) {
|
||||||
|
throw new ApiError('insufficient document access', 403);
|
||||||
|
}
|
||||||
|
const statement = options.sql;
|
||||||
|
// A very loose test, just for early error message
|
||||||
|
if (!(statement.toLowerCase().includes('select'))) {
|
||||||
|
throw new ApiError('only select statements are supported', 400);
|
||||||
|
}
|
||||||
|
const sqlOptions = activeDoc.docStorage.getOptions();
|
||||||
|
if (!sqlOptions?.canInterrupt || !sqlOptions?.bindableMethodsProcessOneStatement) {
|
||||||
|
throw new ApiError('The available SQLite wrapper is not adequate', 500);
|
||||||
|
}
|
||||||
|
const timeout =
|
||||||
|
Math.max(0, Math.min(MAX_CUSTOM_SQL_MSEC,
|
||||||
|
optIntegerParam(options.timeout) || MAX_CUSTOM_SQL_MSEC));
|
||||||
|
// Wrap in a select to commit to the SELECT branch of SQLite
|
||||||
|
// grammar. Note ; isn't a problem.
|
||||||
|
//
|
||||||
|
// The underlying SQLite functions used will only process the
|
||||||
|
// first statement in the supplied text. For node-sqlite3, the
|
||||||
|
// remainder is placed in a "tail string" ignored by that library.
|
||||||
|
// So a Robert'); DROP TABLE Students;-- style attack isn't applicable.
|
||||||
|
//
|
||||||
|
// Since Grist is used with multiple SQLite wrappers, not just
|
||||||
|
// node-sqlite3, we have added a bindableMethodsProcessOneStatement
|
||||||
|
// flag that will need adding for each wrapper, and this endpoint
|
||||||
|
// will not operate unless that flag is set to true.
|
||||||
|
//
|
||||||
|
// The text is wrapped in select * from (USER SUPPLIED TEXT) which
|
||||||
|
// puts SQLite unconditionally onto the SELECT branch of its
|
||||||
|
// grammar. It is straightforward to break out of such a wrapper
|
||||||
|
// with multiple statements, but again, only the first statement
|
||||||
|
// is processed.
|
||||||
|
const wrappedStatement = `select * from (${statement})`;
|
||||||
|
const interrupt = setTimeout(async () => {
|
||||||
|
await activeDoc.docStorage.interrupt();
|
||||||
|
}, timeout);
|
||||||
|
try {
|
||||||
|
const records = await activeDoc.docStorage.all(wrappedStatement,
|
||||||
|
...(options.args || []));
|
||||||
|
res.status(200).json({
|
||||||
|
statement,
|
||||||
|
records: records.map(
|
||||||
|
rec => ({
|
||||||
|
fields: rec,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.code === 'SQLITE_INTERRUPT') {
|
||||||
|
res.status(400).json({
|
||||||
|
error: "a slow statement resulted in a database interrupt",
|
||||||
|
});
|
||||||
|
} else if (e?.code === 'SQLITE_ERROR') {
|
||||||
|
res.status(400).json({
|
||||||
|
error: e?.message,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(interrupt);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addDocApiRoutes(
|
export function addDocApiRoutes(
|
||||||
|
@ -31,6 +31,7 @@ import {ISQLiteDB, MigrationHooks, OpenMode, PreparedStatement, quoteIdent,
|
|||||||
import chunk = require('lodash/chunk');
|
import chunk = require('lodash/chunk');
|
||||||
import cloneDeep = require('lodash/cloneDeep');
|
import cloneDeep = require('lodash/cloneDeep');
|
||||||
import groupBy = require('lodash/groupBy');
|
import groupBy = require('lodash/groupBy');
|
||||||
|
import { MinDBOptions } from './SqliteCommon';
|
||||||
|
|
||||||
|
|
||||||
// Run with environment variable NODE_DEBUG=db (may include additional comma-separated sections)
|
// Run with environment variable NODE_DEBUG=db (may include additional comma-separated sections)
|
||||||
@ -1419,6 +1420,14 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interrupt(): Promise<void> {
|
||||||
|
return this._getDB().interrupt();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getOptions(): MinDBOptions|undefined {
|
||||||
|
return this._getDB().getOptions();
|
||||||
|
}
|
||||||
|
|
||||||
public all(sql: string, ...args: any[]): Promise<ResultRow[]> {
|
public all(sql: string, ...args: any[]): Promise<ResultRow[]> {
|
||||||
return this._getDB().all(sql, ...args);
|
return this._getDB().all(sql, ...args);
|
||||||
}
|
}
|
||||||
|
@ -72,7 +72,7 @@ import {timeFormat} from 'app/common/timeFormat';
|
|||||||
import {create} from 'app/server/lib/create';
|
import {create} from 'app/server/lib/create';
|
||||||
import * as docUtils from 'app/server/lib/docUtils';
|
import * as docUtils from 'app/server/lib/docUtils';
|
||||||
import log from 'app/server/lib/log';
|
import log from 'app/server/lib/log';
|
||||||
import {MinDB, MinRunResult, PreparedStatement, ResultRow,
|
import {MinDB, MinDBOptions, MinRunResult, PreparedStatement, ResultRow,
|
||||||
SqliteVariant, Statement} from 'app/server/lib/SqliteCommon';
|
SqliteVariant, Statement} from 'app/server/lib/SqliteCommon';
|
||||||
import {NodeSqliteVariant} from 'app/server/lib/SqliteNode';
|
import {NodeSqliteVariant} from 'app/server/lib/SqliteNode';
|
||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
@ -258,6 +258,14 @@ export class SQLiteDB implements ISQLiteDB {
|
|||||||
private constructor(protected _db: MinDB, private _dbPath: string) {
|
private constructor(protected _db: MinDB, private _dbPath: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async interrupt(): Promise<void> {
|
||||||
|
return this._db.interrupt?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getOptions(): MinDBOptions|undefined {
|
||||||
|
return this._db.getOptions?.();
|
||||||
|
}
|
||||||
|
|
||||||
public async all(sql: string, ...args: any[]): Promise<ResultRow[]> {
|
public async all(sql: string, ...args: any[]): Promise<ResultRow[]> {
|
||||||
const result = await this._db.all(sql, ...args);
|
const result = await this._db.all(sql, ...args);
|
||||||
return result;
|
return result;
|
||||||
|
@ -12,20 +12,50 @@ import { OpenMode, quoteIdent } from 'app/server/lib/SQLiteDB';
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
export interface Statement {}
|
export interface Statement {}
|
||||||
|
|
||||||
|
// Some facts about the wrapper implementation.
|
||||||
|
export interface MinDBOptions {
|
||||||
|
// is interruption implemented?
|
||||||
|
canInterrupt: boolean;
|
||||||
|
|
||||||
|
// Do all methods apart from exec() process at most one
|
||||||
|
// statement?
|
||||||
|
bindableMethodsProcessOneStatement: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MinDB {
|
export interface MinDB {
|
||||||
|
// This method is expected to be able to handle multiple
|
||||||
|
// semicolon-separated statements, as for sqlite3_exec:
|
||||||
|
// https://www.sqlite.org/c3ref/exec.html
|
||||||
exec(sql: string): Promise<void>;
|
exec(sql: string): Promise<void>;
|
||||||
|
|
||||||
|
// For all these methods, sql should ultimately be passed
|
||||||
|
// to sqlite3_prepare_v2 or later, and any tail text ignored after
|
||||||
|
// the first complete statement, so only the first statement is
|
||||||
|
// used if there are multiple.
|
||||||
|
// https://www.sqlite.org/c3ref/prepare.html
|
||||||
run(sql: string, ...params: any[]): Promise<MinRunResult>;
|
run(sql: string, ...params: any[]): Promise<MinRunResult>;
|
||||||
get(sql: string, ...params: any[]): Promise<ResultRow|undefined>;
|
get(sql: string, ...params: any[]): Promise<ResultRow|undefined>;
|
||||||
all(sql: string, ...params: any[]): Promise<ResultRow[]>;
|
all(sql: string, ...params: any[]): Promise<ResultRow[]>;
|
||||||
prepare(sql: string, ...params: any[]): Promise<PreparedStatement>;
|
prepare(sql: string, ...params: any[]): Promise<PreparedStatement>;
|
||||||
runAndGetId(sql: string, ...params: any[]): Promise<number>;
|
runAndGetId(sql: string, ...params: any[]): Promise<number>;
|
||||||
close(): Promise<void>;
|
|
||||||
allMarshal(sql: string, ...params: any[]): Promise<Buffer>;
|
allMarshal(sql: string, ...params: any[]): Promise<Buffer>;
|
||||||
|
|
||||||
|
close(): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Limit the number of ATTACHed databases permitted.
|
* Limit the number of ATTACHed databases permitted.
|
||||||
*/
|
*/
|
||||||
limitAttach(maxAttach: number): Promise<void>;
|
limitAttach(maxAttach: number): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop all current queries.
|
||||||
|
*/
|
||||||
|
interrupt?(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get some facts about the wrapper.
|
||||||
|
*/
|
||||||
|
getOptions?(): MinDBOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MinRunResult {
|
export interface MinRunResult {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import * as sqlite3 from '@gristlabs/sqlite3';
|
import * as sqlite3 from '@gristlabs/sqlite3';
|
||||||
import { fromCallback } from 'app/server/lib/serverUtils';
|
import { fromCallback } from 'app/server/lib/serverUtils';
|
||||||
import { MinDB, PreparedStatement, ResultRow, SqliteVariant } from 'app/server/lib/SqliteCommon';
|
import { MinDB, MinDBOptions, PreparedStatement, ResultRow, SqliteVariant } from 'app/server/lib/SqliteCommon';
|
||||||
import { OpenMode, RunResult } from 'app/server/lib/SQLiteDB';
|
import { OpenMode, RunResult } from 'app/server/lib/SQLiteDB';
|
||||||
|
|
||||||
export class NodeSqliteVariant implements SqliteVariant {
|
export class NodeSqliteVariant implements SqliteVariant {
|
||||||
@ -84,6 +84,17 @@ export class NodeSqlite3DatabaseAdapter implements MinDB {
|
|||||||
this._db.close();
|
this._db.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async interrupt(): Promise<void> {
|
||||||
|
this._db.interrupt();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getOptions(): MinDBOptions {
|
||||||
|
return {
|
||||||
|
canInterrupt: true,
|
||||||
|
bindableMethodsProcessOneStatement: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async allMarshal(sql: string, ...params: any[]): Promise<Buffer> {
|
public async allMarshal(sql: string, ...params: any[]): Promise<Buffer> {
|
||||||
// allMarshal isn't in the typings, because it is our addition to our fork of sqlite3 JS lib.
|
// allMarshal isn't in the typings, because it is our addition to our fork of sqlite3 JS lib.
|
||||||
return fromCallback(cb => (this._db as any).allMarshal(sql, ...params, cb));
|
return fromCallback(cb => (this._db as any).allMarshal(sql, ...params, cb));
|
||||||
|
@ -874,6 +874,15 @@ function testDocApi() {
|
|||||||
resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/_grist_Tables/data/delete`,
|
resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/_grist_Tables/data/delete`,
|
||||||
[2, 3, 4, 5, 6], chimpy);
|
[2, 3, 4, 5, 6], chimpy);
|
||||||
assert.equal(resp.status, 200);
|
assert.equal(resp.status, 200);
|
||||||
|
|
||||||
|
// Despite deleting tables (even in a more official way than above),
|
||||||
|
// there are rules lingering relating to them. TODO: look into this.
|
||||||
|
resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/_grist_ACLRules/data/delete`,
|
||||||
|
[2, 3], chimpy);
|
||||||
|
assert.equal(resp.status, 200);
|
||||||
|
resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/_grist_ACLResources/data/delete`,
|
||||||
|
[2, 3], chimpy);
|
||||||
|
assert.equal(resp.status, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("/docs/{did}/tables/{tid}/columns", function () {
|
describe("/docs/{did}/tables/{tid}/columns", function () {
|
||||||
@ -3174,6 +3183,7 @@ function testDocApi() {
|
|||||||
users: {"kiwi@getgrist.com": 'editors' as string | null}
|
users: {"kiwi@getgrist.com": 'editors' as string | null}
|
||||||
};
|
};
|
||||||
let accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
|
let accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
|
||||||
|
await flushAuth();
|
||||||
assert.equal(accessResp.status, 200);
|
assert.equal(accessResp.status, 200);
|
||||||
|
|
||||||
const check = userCheck.bind(null, kiwi);
|
const check = userCheck.bind(null, kiwi);
|
||||||
@ -3198,6 +3208,7 @@ function testDocApi() {
|
|||||||
delta.users['kiwi@getgrist.com'] = null;
|
delta.users['kiwi@getgrist.com'] = null;
|
||||||
accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
|
accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
|
||||||
assert.equal(accessResp.status, 200);
|
assert.equal(accessResp.status, 200);
|
||||||
|
await flushAuth();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("DELETE /docs/{did}/tables/webhooks should not be allowed for not-owner", async function () {
|
it("DELETE /docs/{did}/tables/webhooks should not be allowed for not-owner", async function () {
|
||||||
@ -3210,6 +3221,7 @@ function testDocApi() {
|
|||||||
};
|
};
|
||||||
let accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
|
let accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
|
||||||
assert.equal(accessResp.status, 200);
|
assert.equal(accessResp.status, 200);
|
||||||
|
await flushAuth();
|
||||||
|
|
||||||
// Actually unsubscribe with the same unsubscribeKey that was returned by registration - it shouldn't be accepted
|
// Actually unsubscribe with the same unsubscribeKey that was returned by registration - it shouldn't be accepted
|
||||||
await check(subscribeResponse.webhookId, 403, /No owner access/);
|
await check(subscribeResponse.webhookId, 403, /No owner access/);
|
||||||
@ -3219,6 +3231,7 @@ function testDocApi() {
|
|||||||
delta.users['kiwi@getgrist.com'] = null;
|
delta.users['kiwi@getgrist.com'] = null;
|
||||||
accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
|
accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
|
||||||
assert.equal(accessResp.status, 200);
|
assert.equal(accessResp.status, 200);
|
||||||
|
await flushAuth();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -4502,6 +4515,160 @@ function testDocApi() {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it ("GET /docs/{did}/sql is functional", async function () {
|
||||||
|
const query = 'select+*+from+Table1+order+by+id';
|
||||||
|
const resp = await axios.get(`${homeUrl}/api/docs/${docIds.Timesheets}/sql?q=${query}`, chimpy);
|
||||||
|
assert.equal(resp.status, 200);
|
||||||
|
assert.deepEqual(resp.data, {
|
||||||
|
statement: 'select * from Table1 order by id',
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
fields: {
|
||||||
|
id: 1,
|
||||||
|
manualSort: 1,
|
||||||
|
A: 'hello',
|
||||||
|
B: '',
|
||||||
|
C: '',
|
||||||
|
D: null,
|
||||||
|
E: 'HELLO'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: { id: 2, manualSort: 2, A: '', B: 'world', C: '', D: null, E: '' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: { id: 3, manualSort: 3, A: '', B: '', C: '', D: null, E: '' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: { id: 4, manualSort: 4, A: '', B: '', C: '', D: null, E: '' }
|
||||||
|
},
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("POST /docs/{did}/sql is functional", async function () {
|
||||||
|
let resp = await axios.post(
|
||||||
|
`${homeUrl}/api/docs/${docIds.Timesheets}/sql`,
|
||||||
|
{ sql: "select A from Table1 where id = ?", args: [ 1 ] },
|
||||||
|
chimpy);
|
||||||
|
assert.equal(resp.status, 200);
|
||||||
|
assert.deepEqual(resp.data.records, [{
|
||||||
|
fields: {
|
||||||
|
A: 'hello',
|
||||||
|
}
|
||||||
|
}]);
|
||||||
|
|
||||||
|
resp = await axios.post(
|
||||||
|
`${homeUrl}/api/docs/${docIds.Timesheets}/sql`,
|
||||||
|
{ nosql: "select A from Table1 where id = ?", args: [ 1 ] },
|
||||||
|
chimpy);
|
||||||
|
assert.equal(resp.status, 400);
|
||||||
|
assert.deepEqual(resp.data, {
|
||||||
|
error: 'Invalid payload',
|
||||||
|
details: { userError: 'Error: body.sql is missing' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("POST /docs/{did}/sql has access control", async function () {
|
||||||
|
// Check non-viewer doesn't have access.
|
||||||
|
const url = `${homeUrl}/api/docs/${docIds.Timesheets}/sql`;
|
||||||
|
const query = { sql: "select A from Table1 where id = ?", args: [ 1 ] };
|
||||||
|
let resp = await axios.post(url, query, kiwi);
|
||||||
|
assert.equal(resp.status, 403);
|
||||||
|
assert.deepEqual(resp.data, {
|
||||||
|
error: 'No view access',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check a viewer would have access.
|
||||||
|
const delta = {
|
||||||
|
users: { 'kiwi@getgrist.com': 'viewers' },
|
||||||
|
};
|
||||||
|
await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
|
||||||
|
await flushAuth();
|
||||||
|
resp = await axios.post(url, query, kiwi);
|
||||||
|
assert.equal(resp.status, 200);
|
||||||
|
|
||||||
|
// Check a viewer would not have access if there is some private material.
|
||||||
|
await axios.post(
|
||||||
|
`${homeUrl}/api/docs/${docIds.Timesheets}/apply`, [
|
||||||
|
['AddTable', 'TablePrivate', [{id: 'A', type: 'Int'}]],
|
||||||
|
['AddRecord', '_grist_ACLResources', -1, {tableId: 'TablePrivate', colIds: '*'}],
|
||||||
|
['AddRecord', '_grist_ACLRules', null, {
|
||||||
|
resource: -1, aclFormula: '', permissionsText: 'none',
|
||||||
|
}],
|
||||||
|
], chimpy);
|
||||||
|
resp = await axios.post(url, query, kiwi);
|
||||||
|
assert.equal(resp.status, 403);
|
||||||
|
} finally {
|
||||||
|
// Remove extra viewer; remove extra table.
|
||||||
|
const delta = {
|
||||||
|
users: { 'kiwi@getgrist.com': null },
|
||||||
|
};
|
||||||
|
await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
|
||||||
|
await flushAuth();
|
||||||
|
await axios.post(
|
||||||
|
`${homeUrl}/api/docs/${docIds.Timesheets}/apply`, [
|
||||||
|
['RemoveTable', 'TablePrivate'],
|
||||||
|
], chimpy);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("POST /docs/{did}/sql accepts only selects", async function () {
|
||||||
|
async function check(accept: boolean, sql: string, ...args: any[]) {
|
||||||
|
const resp = await axios.post(
|
||||||
|
`${homeUrl}/api/docs/${docIds.Timesheets}/sql`,
|
||||||
|
{ sql, args },
|
||||||
|
chimpy);
|
||||||
|
if (accept) {
|
||||||
|
assert.equal(resp.status, 200);
|
||||||
|
} else {
|
||||||
|
assert.equal(resp.status, 400);
|
||||||
|
}
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
await check(true, 'select * from Table1');
|
||||||
|
await check(true, ' SeLeCT * from Table1');
|
||||||
|
await check(true, 'with results as (select 1) select * from results');
|
||||||
|
|
||||||
|
// rejected quickly since no select
|
||||||
|
await check(false, 'delete from Table1');
|
||||||
|
await check(false, '');
|
||||||
|
|
||||||
|
// rejected because deletes/updates/... can't be nested within a select
|
||||||
|
await check(false, "delete from Table1 where id in (select id from Table1) and 'selecty' = 'selecty'");
|
||||||
|
await check(false, "update Table1 set A = ? where 'selecty' = 'selecty'", 'test');
|
||||||
|
await check(false, "pragma data_store_directory = 'selecty'");
|
||||||
|
await check(false, "create table selecty(x, y)");
|
||||||
|
await check(false, "attach database 'selecty' AS test");
|
||||||
|
|
||||||
|
// rejected because ";" can't be nested
|
||||||
|
await check(false, 'select * from Table1; delete from Table1');
|
||||||
|
|
||||||
|
// Of course, we can get out of the wrapping select, but we can't
|
||||||
|
// add on more statements. For example, the following runs with no
|
||||||
|
// trouble - but only the SELECT part. The DELETE is discarded.
|
||||||
|
// (node-sqlite3 doesn't expose enough to give an error message for
|
||||||
|
// this, though we could extend it).
|
||||||
|
await check(true, 'select * from Table1); delete from Table1 where id in (select id from Table1');
|
||||||
|
const {records} = await check(true, 'select * from Table1');
|
||||||
|
// Double-check the deletion didn't happen.
|
||||||
|
assert.lengthOf(records, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it ("POST /docs/{did}/sql timeout is effective", async function () {
|
||||||
|
const slowQuery = 'WITH RECURSIVE r(i) AS (VALUES(0) ' +
|
||||||
|
'UNION ALL SELECT i FROM r LIMIT 1000000) ' +
|
||||||
|
'SELECT i FROM r WHERE i = 1';
|
||||||
|
const resp = await axios.post(
|
||||||
|
`${homeUrl}/api/docs/${docIds.Timesheets}/sql`,
|
||||||
|
{ sql: slowQuery, timeout: 10 },
|
||||||
|
chimpy);
|
||||||
|
assert.equal(resp.status, 400);
|
||||||
|
assert.match(resp.data.error, /database interrupt/);
|
||||||
|
});
|
||||||
|
|
||||||
// PLEASE ADD MORE TESTS HERE
|
// PLEASE ADD MORE TESTS HERE
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4558,3 +4725,10 @@ async function setupDataDir(dir: string) {
|
|||||||
'ApiDataRecordsTest.grist',
|
'ApiDataRecordsTest.grist',
|
||||||
path.resolve(dir, docIds.ApiDataRecordsTest + '.grist'));
|
path.resolve(dir, docIds.ApiDataRecordsTest + '.grist'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The access control level of a user on a document may be cached for a
|
||||||
|
// few seconds. This method flushes that cache.
|
||||||
|
async function flushAuth() {
|
||||||
|
await home.testingHooks.flushAuthorizerCache();
|
||||||
|
await docs.testingHooks.flushAuthorizerCache();
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user