mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) support granular read access for attachments
Summary: When a user requests to read the contents of an attachment, only allow the request if there exists a cell in an attachment column that contains the attachment and which they have read access to. This does not cover: * Granular write access for attachments. In particular, a user who can write to any attachment column should be considered to have full read access to all attachment columns, currently. * Access control of attachment metadata such as name and format. The implementation uses a sql query that requires a scan, and some notes on how this could be optimized in future. The web client was updated to specify the cell to check for access, and performance seemed fine in casual testing on a doc with 1000s of attachments. I'm not sure how performance would hold up as the set of access rules grows as well. Test Plan: added tests Reviewers: alexmojaki Reviewed By: alexmojaki Differential Revision: https://phab.getgrist.com/D3490
This commit is contained in:
parent
56c9d2cfe9
commit
f91f45b26d
@ -16,6 +16,7 @@ import {IModalControl, modal} from 'app/client/ui2018/modals';
|
|||||||
import {renderFileType} from 'app/client/widgets/AttachmentsWidget';
|
import {renderFileType} from 'app/client/widgets/AttachmentsWidget';
|
||||||
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
|
||||||
import {CellValue} from 'app/common/DocActions';
|
import {CellValue} from 'app/common/DocActions';
|
||||||
|
import {SingleCell} from 'app/common/TableData';
|
||||||
import {clamp, encodeQueryParams} from 'app/common/gutil';
|
import {clamp, encodeQueryParams} from 'app/common/gutil';
|
||||||
import {UploadResult} from 'app/common/uploads';
|
import {UploadResult} from 'app/common/uploads';
|
||||||
import * as mimeTypes from 'mime-types';
|
import * as mimeTypes from 'mime-types';
|
||||||
@ -60,6 +61,11 @@ export class AttachmentsEditor extends NewBaseEditor {
|
|||||||
|
|
||||||
const docData: DocData = options.gristDoc.docData;
|
const docData: DocData = options.gristDoc.docData;
|
||||||
const cellValue: CellValue = options.cellValue;
|
const cellValue: CellValue = options.cellValue;
|
||||||
|
const cell: SingleCell = {
|
||||||
|
rowId: options.rowId,
|
||||||
|
colId: options.field.colId(),
|
||||||
|
tableId: options.field.column().table().tableId(),
|
||||||
|
};
|
||||||
|
|
||||||
// editValue is abused slightly to indicate a 1-based index of the attachment.
|
// editValue is abused slightly to indicate a 1-based index of the attachment.
|
||||||
const initRowIndex: number|undefined = (options.editValue && parseInt(options.editValue, 0) - 1) || 0;
|
const initRowIndex: number|undefined = (options.editValue && parseInt(options.editValue, 0) - 1) || 0;
|
||||||
@ -79,8 +85,8 @@ export class AttachmentsEditor extends NewBaseEditor {
|
|||||||
fileType,
|
fileType,
|
||||||
filename,
|
filename,
|
||||||
hasPreview: Boolean(this._attachmentsTable.getValue(val, 'imageHeight')),
|
hasPreview: Boolean(this._attachmentsTable.getValue(val, 'imageHeight')),
|
||||||
url: computed((use) => this._getUrl(fileIdent, use(filename))),
|
url: computed((use) => this._getUrl(cell, val, use(filename))),
|
||||||
inlineUrl: computed((use) => this._getUrl(fileIdent, use(filename), true))
|
inlineUrl: computed((use) => this._getUrl(cell, val, use(filename), true))
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
this._index = makeLiveIndex(this, this._attachments, initRowIndex);
|
this._index = makeLiveIndex(this, this._attachments, initRowIndex);
|
||||||
@ -190,11 +196,12 @@ export class AttachmentsEditor extends NewBaseEditor {
|
|||||||
att.filename.set(this._attachmentsTable.getValue(att.rowId, 'fileName')!);
|
att.filename.set(this._attachmentsTable.getValue(att.rowId, 'fileName')!);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getUrl(fileIdent: string, filename: string, inline?: boolean): string {
|
private _getUrl(cell: SingleCell, attId: number, filename: string, inline?: boolean): string {
|
||||||
return this._docComm.docUrl('attachment') + '?' + encodeQueryParams({
|
return this._docComm.docUrl('attachment') + '?' + encodeQueryParams({
|
||||||
...this._docComm.getUrlParams(),
|
...this._docComm.getUrlParams(),
|
||||||
ident: fileIdent,
|
|
||||||
name: filename,
|
name: filename,
|
||||||
|
...cell,
|
||||||
|
attId,
|
||||||
...(inline ? {inline: 1} : {})
|
...(inline ? {inline: 1} : {})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import {encodeQueryParams} from 'app/common/gutil';
|
|||||||
import {MetaTableData} from 'app/client/models/TableData';
|
import {MetaTableData} from 'app/client/models/TableData';
|
||||||
import {UploadResult} from 'app/common/uploads';
|
import {UploadResult} from 'app/common/uploads';
|
||||||
import {extname} from 'path';
|
import {extname} from 'path';
|
||||||
|
import { SingleCell } from 'app/common/TableData';
|
||||||
|
|
||||||
const testId: TestId = makeTestId('test-pw-');
|
const testId: TestId = makeTestId('test-pw-');
|
||||||
|
|
||||||
@ -90,6 +91,8 @@ export class AttachmentsWidget extends NewAbstractWidget {
|
|||||||
const values = Computed.create(null, fromKo(cellValue), (use, _cellValue) =>
|
const values = Computed.create(null, fromKo(cellValue), (use, _cellValue) =>
|
||||||
Array.isArray(_cellValue) ? _cellValue.slice(1) : []);
|
Array.isArray(_cellValue) ? _cellValue.slice(1) : []);
|
||||||
|
|
||||||
|
const colId = this.field.colId();
|
||||||
|
const tableId = this.field.column().table().tableId();
|
||||||
return attachmentWidget(
|
return attachmentWidget(
|
||||||
dom.autoDispose(values),
|
dom.autoDispose(values),
|
||||||
|
|
||||||
@ -98,9 +101,12 @@ export class AttachmentsWidget extends NewAbstractWidget {
|
|||||||
dom.cls('attachment_hover_icon', (use) => use(values).length > 0),
|
dom.cls('attachment_hover_icon', (use) => use(values).length > 0),
|
||||||
dom.on('click', () => this._selectAndSave(cellValue))
|
dom.on('click', () => this._selectAndSave(cellValue))
|
||||||
),
|
),
|
||||||
dom.forEach(values, (value: number) =>
|
dom.maybe(_row.id, rowId => {
|
||||||
isNaN(value) ? null : this._buildAttachment(value, values)
|
return dom.forEach(values, (value: number) =>
|
||||||
),
|
isNaN(value) ? null : this._buildAttachment(value, values, {
|
||||||
|
rowId, colId, tableId,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
dom.on('drop', ev => this._uploadAndSave(cellValue, ev.dataTransfer!.files))
|
dom.on('drop', ev => this._uploadAndSave(cellValue, ev.dataTransfer!.files))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -121,7 +127,7 @@ export class AttachmentsWidget extends NewAbstractWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _buildAttachment(value: number, allValues: Computed<number[]>): Element {
|
protected _buildAttachment(value: number, allValues: Computed<number[]>, cell: SingleCell): Element {
|
||||||
const filename = this._attachmentsTable.getValue(value, 'fileName')!;
|
const filename = this._attachmentsTable.getValue(value, 'fileName')!;
|
||||||
const fileIdent = this._attachmentsTable.getValue(value, 'fileIdent')!;
|
const fileIdent = this._attachmentsTable.getValue(value, 'fileIdent')!;
|
||||||
const height = this._attachmentsTable.getValue(value, 'imageHeight')!;
|
const height = this._attachmentsTable.getValue(value, 'imageHeight')!;
|
||||||
@ -134,7 +140,7 @@ export class AttachmentsWidget extends NewAbstractWidget {
|
|||||||
dom.style('width', (use) => `${parseInt(use(this._height), 10) * ratio}px`),
|
dom.style('width', (use) => `${parseInt(use(this._height), 10) * ratio}px`),
|
||||||
// TODO: Update to legitimately determine whether a file preview exists.
|
// TODO: Update to legitimately determine whether a file preview exists.
|
||||||
hasPreview ? dom('img', {style: 'height: 100%; min-width: 100%; vertical-align: top;'},
|
hasPreview ? dom('img', {style: 'height: 100%; min-width: 100%; vertical-align: top;'},
|
||||||
dom.attr('src', this._getUrl(value))
|
dom.attr('src', this._getUrl(value, cell))
|
||||||
) : renderFileType(filename, fileIdent, this._height),
|
) : renderFileType(filename, fileIdent, this._height),
|
||||||
// Open editor as if with input, using it to tell it which of the attachments to show. We
|
// Open editor as if with input, using it to tell it which of the attachments to show. We
|
||||||
// pass in a 1-based index. Hitting a key opens the cell, and this approach allows an
|
// pass in a 1-based index. Hitting a key opens the cell, and this approach allows an
|
||||||
@ -145,18 +151,14 @@ export class AttachmentsWidget extends NewAbstractWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Returns the attachment download url.
|
// Returns the attachment download url.
|
||||||
private _getUrl(rowId: number): string {
|
private _getUrl(attId: number, cell: SingleCell): string {
|
||||||
const ident = this._attachmentsTable.getValue(rowId, 'fileIdent');
|
const docComm = this._getDocComm();
|
||||||
if (!ident) {
|
return docComm.docUrl('attachment') + '?' + encodeQueryParams({
|
||||||
return '';
|
...docComm.getUrlParams(),
|
||||||
} else {
|
...cell,
|
||||||
const docComm = this._getDocComm();
|
attId,
|
||||||
return docComm.docUrl('attachment') + '?' + encodeQueryParams({
|
name: this._attachmentsTable.getValue(attId, 'fileName')
|
||||||
...docComm.getUrlParams(),
|
});
|
||||||
ident,
|
|
||||||
name: this._attachmentsTable.getValue(rowId, 'fileName')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _selectAndSave(value: SavingObservable<number[]>): Promise<void> {
|
private async _selectAndSave(value: SavingObservable<number[]>): Promise<void> {
|
||||||
|
@ -204,6 +204,7 @@ export class FieldEditor extends Disposable {
|
|||||||
gristDoc: this._gristDoc,
|
gristDoc: this._gristDoc,
|
||||||
field: this._field,
|
field: this._field,
|
||||||
cellValue,
|
cellValue,
|
||||||
|
rowId: this._editRow.id(),
|
||||||
formulaError: error,
|
formulaError: error,
|
||||||
editValue,
|
editValue,
|
||||||
cursorPos,
|
cursorPos,
|
||||||
|
@ -292,6 +292,7 @@ export function openFormulaEditor(options: {
|
|||||||
const editor = FormulaEditor.create(holder, {
|
const editor = FormulaEditor.create(holder, {
|
||||||
gristDoc,
|
gristDoc,
|
||||||
field,
|
field,
|
||||||
|
rowId: editRow ? editRow.id() : 0,
|
||||||
cellValue: column.formula(),
|
cellValue: column.formula(),
|
||||||
formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
|
formulaError: editRow ? getFormulaError(gristDoc, editRow, column) : undefined,
|
||||||
editValue: options.editValue,
|
editValue: options.editValue,
|
||||||
|
@ -17,6 +17,7 @@ export interface Options {
|
|||||||
gristDoc: GristDoc;
|
gristDoc: GristDoc;
|
||||||
field: ViewFieldRec;
|
field: ViewFieldRec;
|
||||||
cellValue: CellValue;
|
cellValue: CellValue;
|
||||||
|
rowId: number;
|
||||||
formulaError?: Observable<CellValue>;
|
formulaError?: Observable<CellValue>;
|
||||||
editValue?: string;
|
editValue?: string;
|
||||||
cursorPos: number;
|
cursorPos: number;
|
||||||
|
@ -22,6 +22,12 @@ interface ColData {
|
|||||||
values: CellValue[];
|
values: CellValue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SingleCell {
|
||||||
|
tableId: string;
|
||||||
|
colId: string;
|
||||||
|
rowId: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An interface for a table with rows that may be skipped.
|
* An interface for a table with rows that may be skipped.
|
||||||
*/
|
*/
|
||||||
|
@ -51,13 +51,14 @@ import {
|
|||||||
getUsageRatio,
|
getUsageRatio,
|
||||||
} from 'app/common/DocUsage';
|
} from 'app/common/DocUsage';
|
||||||
import {normalizeEmail} from 'app/common/emails';
|
import {normalizeEmail} from 'app/common/emails';
|
||||||
|
import {ErrorWithCode} from 'app/common/ErrorWithCode';
|
||||||
import {Product} from 'app/common/Features';
|
import {Product} from 'app/common/Features';
|
||||||
import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccessClause';
|
import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccessClause';
|
||||||
import {parseUrlId} from 'app/common/gristUrls';
|
import {parseUrlId} from 'app/common/gristUrls';
|
||||||
import {byteString, countIf, retryOnce, safeJsonParse} from 'app/common/gutil';
|
import {byteString, countIf, retryOnce, safeJsonParse} from 'app/common/gutil';
|
||||||
import {InactivityTimer} from 'app/common/InactivityTimer';
|
import {InactivityTimer} from 'app/common/InactivityTimer';
|
||||||
import {schema, SCHEMA_VERSION} from 'app/common/schema';
|
import {schema, SCHEMA_VERSION} from 'app/common/schema';
|
||||||
import {MetaRowRecord} from 'app/common/TableData';
|
import {MetaRowRecord, SingleCell} from 'app/common/TableData';
|
||||||
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
||||||
import {DocReplacementOptions, DocState, DocStateComparison} from 'app/common/UserAPI';
|
import {DocReplacementOptions, DocState, DocStateComparison} from 'app/common/UserAPI';
|
||||||
import {convertFromColumn} from 'app/common/ValueConverter';
|
import {convertFromColumn} from 'app/common/ValueConverter';
|
||||||
@ -659,7 +660,7 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
this._recoveryMode);
|
this._recoveryMode);
|
||||||
|
|
||||||
await this._actionHistory.initialize();
|
await this._actionHistory.initialize();
|
||||||
this._granularAccess = new GranularAccess(this.docData, this.docClients, (query) => {
|
this._granularAccess = new GranularAccess(this.docData, this.docStorage, this.docClients, (query) => {
|
||||||
return this._fetchQueryFromDB(query, false);
|
return this._fetchQueryFromDB(query, false);
|
||||||
}, this.recoveryMode, this.getHomeDbManager(), this.docName);
|
}, this.recoveryMode, this.getHomeDbManager(), this.docName);
|
||||||
await this._granularAccess.update();
|
await this._granularAccess.update();
|
||||||
@ -811,14 +812,12 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
* Returns the record from _grist_Attachments table for the given attachment ID,
|
* Returns the record from _grist_Attachments table for the given attachment ID,
|
||||||
* or throws an error if not found.
|
* or throws an error if not found.
|
||||||
*/
|
*/
|
||||||
public getAttachmentMetadata(attId: number|string): MetaRowRecord<'_grist_Attachments'> {
|
public getAttachmentMetadata(attId: number): MetaRowRecord<'_grist_Attachments'> {
|
||||||
// docData should always be available after loadDoc() or createDoc().
|
// docData should always be available after loadDoc() or createDoc().
|
||||||
if (!this.docData) {
|
if (!this.docData) {
|
||||||
throw new Error("No doc data");
|
throw new Error("No doc data");
|
||||||
}
|
}
|
||||||
// Parse strings into numbers to make more convenient to call from route handlers.
|
const attRecord = this.docData.getMetaTable('_grist_Attachments').getRecord(attId);
|
||||||
const attachmentId: number = (typeof attId === 'string') ? parseInt(attId, 10) : attId;
|
|
||||||
const attRecord = this.docData.getMetaTable('_grist_Attachments').getRecord(attachmentId);
|
|
||||||
if (!attRecord) {
|
if (!attRecord) {
|
||||||
throw new ApiError(`Attachment not found: ${attId}`, 404);
|
throw new ApiError(`Attachment not found: ${attId}`, 404);
|
||||||
}
|
}
|
||||||
@ -826,16 +825,49 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given the fileIdent of an attachment, returns a promise for the attachment data.
|
* Given a _gristAttachments record, returns a promise for the attachment data.
|
||||||
* @param {String} fileIdent: The unique identifier of the attachment (as stored in fileIdent
|
|
||||||
* field of the _grist_Attachments table).
|
|
||||||
* @returns {Promise<Buffer>} Promise for the data of this attachment; rejected on error.
|
* @returns {Promise<Buffer>} Promise for the data of this attachment; rejected on error.
|
||||||
*/
|
*/
|
||||||
public async getAttachmentData(docSession: OptDocSession, fileIdent: string): Promise<Buffer> {
|
public async getAttachmentData(docSession: OptDocSession, attRecord: MetaRowRecord<"_grist_Attachments">,
|
||||||
// We don't know for sure whether the attachment is available via a table the user
|
cell?: SingleCell): Promise<Buffer> {
|
||||||
// has access to, but at least they are presenting a SHA1 checksum of the file content,
|
const attId = attRecord.id;
|
||||||
// and they have at least view access to the document to get to this point. So we go ahead
|
const fileIdent = attRecord.fileIdent;
|
||||||
// and serve the attachment.
|
if (
|
||||||
|
await this._granularAccess.canReadEverything(docSession) ||
|
||||||
|
await this.canDownload(docSession)
|
||||||
|
) {
|
||||||
|
// Do not need to sweat over access to attachments if user can
|
||||||
|
// read everything or download everything.
|
||||||
|
} else if (cell) {
|
||||||
|
// Only provide the download if the user has access to the cell
|
||||||
|
// they specified, and that cell is in an attachment column,
|
||||||
|
// and the cell contains the specified attachment.
|
||||||
|
await this._granularAccess.assertAttachmentAccess(docSession, cell, attId);
|
||||||
|
} else {
|
||||||
|
// Find cells that refer to the given attachment.
|
||||||
|
const cells = await this.docStorage.findAttachmentReferences(attId);
|
||||||
|
// Run through them to see if the user has access to any of them.
|
||||||
|
// If so, we'll allow the download. We'd expect in a typical document
|
||||||
|
// this this will be a small list of cells, typically 1 or less, but
|
||||||
|
// of course extreme cases are possible.
|
||||||
|
let goodCell: SingleCell|undefined;
|
||||||
|
for (const possibleCell of cells) {
|
||||||
|
try {
|
||||||
|
await this._granularAccess.assertAttachmentAccess(docSession, possibleCell, attId);
|
||||||
|
goodCell = possibleCell;
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ErrorWithCode && e.code === 'ACL_DENY') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!goodCell) {
|
||||||
|
// We found no reason to allow this user to access the attachment.
|
||||||
|
throw new ApiError('Cannot access attachment', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
const data = await this.docStorage.getFileData(fileIdent);
|
const data = await this.docStorage.getFileData(fileIdent);
|
||||||
if (!data) { throw new ApiError("Invalid attachment identifier", 404); }
|
if (!data) { throw new ApiError("Invalid attachment identifier", 404); }
|
||||||
this._log.info(docSession, "getAttachment: %s -> %s bytes", fileIdent, data.length);
|
this._log.info(docSession, "getAttachment: %s -> %s bytes", fileIdent, data.length);
|
||||||
|
@ -48,6 +48,7 @@ import {
|
|||||||
getScope,
|
getScope,
|
||||||
integerParam,
|
integerParam,
|
||||||
isParameterOn,
|
isParameterOn,
|
||||||
|
optIntegerParam,
|
||||||
optStringParam,
|
optStringParam,
|
||||||
sendOkReply,
|
sendOkReply,
|
||||||
sendReply,
|
sendReply,
|
||||||
@ -249,18 +250,27 @@ export class DocWorkerApi {
|
|||||||
|
|
||||||
// Returns cleaned metadata for a given attachment ID (i.e. a rowId in _grist_Attachments table).
|
// Returns cleaned metadata for a given attachment ID (i.e. a rowId in _grist_Attachments table).
|
||||||
this._app.get('/api/docs/:docId/attachments/:attId', canView, withDoc(async (activeDoc, req, res) => {
|
this._app.get('/api/docs/:docId/attachments/:attId', canView, withDoc(async (activeDoc, req, res) => {
|
||||||
const attRecord = activeDoc.getAttachmentMetadata(req.params.attId as string);
|
const attId = integerParam(req.params.attId, 'attId');
|
||||||
|
const attRecord = activeDoc.getAttachmentMetadata(attId);
|
||||||
res.json(cleanAttachmentRecord(attRecord));
|
res.json(cleanAttachmentRecord(attRecord));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Responds with attachment contents, with suitable Content-Type and Content-Disposition.
|
// Responds with attachment contents, with suitable Content-Type and Content-Disposition.
|
||||||
this._app.get('/api/docs/:docId/attachments/:attId/download', canView, withDoc(async (activeDoc, req, res) => {
|
this._app.get('/api/docs/:docId/attachments/:attId/download', canView, withDoc(async (activeDoc, req, res) => {
|
||||||
const attRecord = activeDoc.getAttachmentMetadata(req.params.attId as string);
|
const attId = integerParam(req.params.attId, 'attId');
|
||||||
|
const tableId = optStringParam(req.params.tableId);
|
||||||
|
const colId = optStringParam(req.params.colId);
|
||||||
|
const rowId = optIntegerParam(req.params.rowId);
|
||||||
|
if ((tableId || colId || rowId) && !(tableId && colId && rowId)) {
|
||||||
|
throw new ApiError('define all of tableId, colId and rowId, or none.', 400);
|
||||||
|
}
|
||||||
|
const attRecord = activeDoc.getAttachmentMetadata(attId);
|
||||||
|
const cell = (tableId && colId && rowId) ? {tableId, colId, rowId} : undefined;
|
||||||
const fileIdent = attRecord.fileIdent as string;
|
const fileIdent = attRecord.fileIdent as string;
|
||||||
const ext = path.extname(fileIdent);
|
const ext = path.extname(fileIdent);
|
||||||
const origName = attRecord.fileName as string;
|
const origName = attRecord.fileName as string;
|
||||||
const fileName = ext ? path.basename(origName, path.extname(origName)) + ext : origName;
|
const fileName = ext ? path.basename(origName, path.extname(origName)) + ext : origName;
|
||||||
const fileData = await activeDoc.getAttachmentData(docSessionFromRequest(req), fileIdent);
|
const fileData = await activeDoc.getAttachmentData(docSessionFromRequest(req), attRecord, cell);
|
||||||
res.status(200)
|
res.status(200)
|
||||||
.type(ext)
|
.type(ext)
|
||||||
// Construct a content-disposition header of the form 'attachment; filename="NAME"'
|
// Construct a content-disposition header of the form 'attachment; filename="NAME"'
|
||||||
|
@ -14,6 +14,7 @@ import * as gristTypes from 'app/common/gristTypes';
|
|||||||
import {isList, isListType, isRefListType} from 'app/common/gristTypes';
|
import {isList, isListType, isRefListType} from 'app/common/gristTypes';
|
||||||
import * as marshal from 'app/common/marshal';
|
import * as marshal from 'app/common/marshal';
|
||||||
import * as schema from 'app/common/schema';
|
import * as schema from 'app/common/schema';
|
||||||
|
import {SingleCell} from 'app/common/TableData';
|
||||||
import {GristObjCode} from "app/plugin/GristData";
|
import {GristObjCode} from "app/plugin/GristData";
|
||||||
import {ActionHistoryImpl} from 'app/server/lib/ActionHistoryImpl';
|
import {ActionHistoryImpl} from 'app/server/lib/ActionHistoryImpl';
|
||||||
import {ExpandedQuery} from 'app/server/lib/ExpandedQuery';
|
import {ExpandedQuery} from 'app/server/lib/ExpandedQuery';
|
||||||
@ -799,6 +800,13 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
|
|||||||
return colData.maxId ? colData.maxId + 1 : 1;
|
return colData.maxId ? colData.maxId + 1 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up Grist type of column.
|
||||||
|
*/
|
||||||
|
public getColumnType(tableId: string, colId: string): string|undefined {
|
||||||
|
return this._docSchema[tableId]?.[colId];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches all rows of the table with the given rowIds.
|
* Fetches all rows of the table with the given rowIds.
|
||||||
*/
|
*/
|
||||||
@ -1305,6 +1313,32 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
|
|||||||
return (await this.all(sql)) as any[];
|
return (await this.all(sql)) as any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all cells that refer to a particular attachment. Ideally this is
|
||||||
|
* something we could use an index for. Regular indexes in SQLite don't help.
|
||||||
|
* FTS5 works, but is somewhat overkill.
|
||||||
|
*/
|
||||||
|
public async findAttachmentReferences(attId: number): Promise<Array<SingleCell>> {
|
||||||
|
const queries: string[] = [];
|
||||||
|
// Switch quotes so to insert a table or column name as a string literal
|
||||||
|
// rather than as an identifier.
|
||||||
|
function asLiteral(name: string) {
|
||||||
|
return quoteIdent(name).replace(/"/g, '\'');
|
||||||
|
}
|
||||||
|
for (const [tableId, cols] of Object.entries(this._docSchema)) {
|
||||||
|
for (const [colId, type] of Object.entries(cols)) {
|
||||||
|
if (type !== "Attachments") { continue; }
|
||||||
|
queries.push(`SELECT
|
||||||
|
t.id as rowId,
|
||||||
|
${asLiteral(tableId)} as tableId,
|
||||||
|
${asLiteral(colId)} as colId
|
||||||
|
FROM ${quoteIdent(tableId)} AS t, json_each(t.${quoteIdent(colId)}) as a
|
||||||
|
WHERE a.value = ${attId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (await this.all(queries.join(' UNION ALL '))) as any[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return row IDs of unused attachments in _grist_Attachments.
|
* Return row IDs of unused attachments in _grist_Attachments.
|
||||||
* Uses the timeDeleted column which is updated in ActiveDoc.updateUsedAttachmentsIfNeeded.
|
* Uses the timeDeleted column which is updated in ActiveDoc.updateUsedAttachmentsIfNeeded.
|
||||||
|
@ -34,7 +34,13 @@ export class DocWorker {
|
|||||||
const docSession = this._getDocSession(stringParam(req.query.clientId, 'clientId'),
|
const docSession = this._getDocSession(stringParam(req.query.clientId, 'clientId'),
|
||||||
integerParam(req.query.docFD, 'docFD'));
|
integerParam(req.query.docFD, 'docFD'));
|
||||||
const activeDoc = docSession.activeDoc;
|
const activeDoc = docSession.activeDoc;
|
||||||
const ext = path.extname(stringParam(req.query.ident, 'ident'));
|
const colId = stringParam(req.query.colId, 'colId');
|
||||||
|
const tableId = stringParam(req.query.tableId, 'tableId');
|
||||||
|
const rowId = integerParam(req.query.rowId, 'rowId');
|
||||||
|
const cell = {colId, tableId, rowId};
|
||||||
|
const attId = integerParam(req.query.attId, 'attId');
|
||||||
|
const attRecord = activeDoc.getAttachmentMetadata(attId);
|
||||||
|
const ext = path.extname(attRecord.fileIdent);
|
||||||
const type = mimeTypes.lookup(ext);
|
const type = mimeTypes.lookup(ext);
|
||||||
|
|
||||||
let inline = Boolean(req.query.inline);
|
let inline = Boolean(req.query.inline);
|
||||||
@ -44,7 +50,7 @@ export class DocWorker {
|
|||||||
// Construct a content-disposition header of the form 'inline|attachment; filename="NAME"'
|
// Construct a content-disposition header of the form 'inline|attachment; filename="NAME"'
|
||||||
const contentDispType = inline ? "inline" : "attachment";
|
const contentDispType = inline ? "inline" : "attachment";
|
||||||
const contentDispHeader = contentDisposition(stringParam(req.query.name, 'name'), {type: contentDispType});
|
const contentDispHeader = contentDisposition(stringParam(req.query.name, 'name'), {type: contentDispType});
|
||||||
const data = await activeDoc.getAttachmentData(docSession, stringParam(req.query.ident, 'ident'));
|
const data = await activeDoc.getAttachmentData(docSession, attRecord, cell);
|
||||||
res.status(200)
|
res.status(200)
|
||||||
.type(ext)
|
.type(ext)
|
||||||
.set('Content-Disposition', contentDispHeader)
|
.set('Content-Disposition', contentDispHeader)
|
||||||
|
@ -21,8 +21,9 @@ import { normalizeEmail } from 'app/common/emails';
|
|||||||
import { ErrorWithCode } from 'app/common/ErrorWithCode';
|
import { ErrorWithCode } from 'app/common/ErrorWithCode';
|
||||||
import { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessClause';
|
import { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessClause';
|
||||||
import { UserInfo } from 'app/common/GranularAccessClause';
|
import { UserInfo } from 'app/common/GranularAccessClause';
|
||||||
import { isCensored } from 'app/common/gristTypes';
|
import * as gristTypes from 'app/common/gristTypes';
|
||||||
import { getSetMapValue, isNonNullish, pruneArray } from 'app/common/gutil';
|
import { getSetMapValue, isNonNullish, pruneArray } from 'app/common/gutil';
|
||||||
|
import { SingleCell } from 'app/common/TableData';
|
||||||
import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
|
import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
|
||||||
import { FullUser, UserAccessData } from 'app/common/UserAPI';
|
import { FullUser, UserAccessData } from 'app/common/UserAPI';
|
||||||
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
|
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
|
||||||
@ -31,6 +32,7 @@ import { compileAclFormula } from 'app/server/lib/ACLFormula';
|
|||||||
import { DocClients } from 'app/server/lib/DocClients';
|
import { DocClients } from 'app/server/lib/DocClients';
|
||||||
import { getDocSessionAccess, getDocSessionAltSessionId, getDocSessionUser,
|
import { getDocSessionAccess, getDocSessionAltSessionId, getDocSessionUser,
|
||||||
OptDocSession } from 'app/server/lib/DocSession';
|
OptDocSession } from 'app/server/lib/DocSession';
|
||||||
|
import { DocStorage } from 'app/server/lib/DocStorage';
|
||||||
import log from 'app/server/lib/log';
|
import log from 'app/server/lib/log';
|
||||||
import { IPermissionInfo, PermissionInfo, PermissionSetWithContext } from 'app/server/lib/PermissionInfo';
|
import { IPermissionInfo, PermissionInfo, PermissionSetWithContext } from 'app/server/lib/PermissionInfo';
|
||||||
import { TablePermissionSetWithContext } from 'app/server/lib/PermissionInfo';
|
import { TablePermissionSetWithContext } from 'app/server/lib/PermissionInfo';
|
||||||
@ -187,6 +189,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private _docData: DocData,
|
private _docData: DocData,
|
||||||
|
private _docStorage: DocStorage,
|
||||||
private _docClients: DocClients,
|
private _docClients: DocClients,
|
||||||
private _fetchQueryFromDB: (query: ServerQuery) => Promise<TableDataAction>,
|
private _fetchQueryFromDB: (query: ServerQuery) => Promise<TableDataAction>,
|
||||||
private _recoveryMode: boolean,
|
private _recoveryMode: boolean,
|
||||||
@ -239,6 +242,57 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
return this.getReadPermission(pset) !== 'deny';
|
return this.getReadPermission(pset) !== 'deny';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get content of a given cell, if user has read access.
|
||||||
|
* Throws if not.
|
||||||
|
*/
|
||||||
|
public async getCellValue(docSession: OptDocSession, cell: SingleCell): Promise<CellValue> {
|
||||||
|
function fail(): never {
|
||||||
|
throw new ErrorWithCode('ACL_DENY', 'Cannot access cell');
|
||||||
|
}
|
||||||
|
const pset = await this.getTableAccess(docSession, cell.tableId);
|
||||||
|
const tableAccess = this.getReadPermission(pset);
|
||||||
|
if (tableAccess === 'deny') { fail(); }
|
||||||
|
const rows = await this._fetchQueryFromDB({
|
||||||
|
tableId: cell.tableId,
|
||||||
|
filters: { id: [cell.rowId] }
|
||||||
|
});
|
||||||
|
if (!rows || rows[2].length === 0) { fail(); }
|
||||||
|
const rec = new RecordView(rows, 0);
|
||||||
|
const input: AclMatchInput = {user: await this._getUser(docSession), rec, newRec: rec};
|
||||||
|
const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);
|
||||||
|
const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;
|
||||||
|
if (rowAccess === 'deny') { fail(); }
|
||||||
|
if (rowAccess !== 'allow') {
|
||||||
|
const colAccess = rowPermInfo.getColumnAccess(cell.tableId, cell.colId).perms.read;
|
||||||
|
if (colAccess === 'deny') { fail(); }
|
||||||
|
}
|
||||||
|
const colValues = rows[3];
|
||||||
|
if (!(cell.colId in colValues)) { fail(); }
|
||||||
|
return rec.get(cell.colId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the specified cell is accessible by the user, and contains
|
||||||
|
* the specified attachment. Throws with ACL_DENY code if not.
|
||||||
|
*/
|
||||||
|
public async assertAttachmentAccess(docSession: OptDocSession, cell: SingleCell, attId: number): Promise<void> {
|
||||||
|
const value = await this.getCellValue(docSession, cell);
|
||||||
|
|
||||||
|
// Need to check column is actually an attachment column.
|
||||||
|
if (this._docStorage.getColumnType(cell.tableId, cell.colId) !== 'Attachments') {
|
||||||
|
throw new ErrorWithCode('ACL_DENY', 'not an attachment column');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that material in cell includes the attachment.
|
||||||
|
if (!gristTypes.isList(value)) {
|
||||||
|
throw new ErrorWithCode('ACL_DENY', 'not a list');
|
||||||
|
}
|
||||||
|
if (value.indexOf(attId) <= 0) {
|
||||||
|
throw new ErrorWithCode('ACL_DENY', 'attachment not present in cell');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called after UserAction[]s have been applied in the sandbox, and DocAction[]s have been
|
* Called after UserAction[]s have been applied in the sandbox, and DocAction[]s have been
|
||||||
* computed, but before we have committed those DocAction[]s to the database. If this
|
* computed, but before we have committed those DocAction[]s to the database. If this
|
||||||
@ -1728,7 +1782,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
return [filteredAction];
|
return [filteredAction];
|
||||||
}
|
}
|
||||||
|
|
||||||
return filterColValues(filteredAction, (idx) => censoredRows.has(idx), isCensored);
|
return filterColValues(filteredAction, (idx) => censoredRows.has(idx), gristTypes.isCensored);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -258,8 +258,14 @@ export function stringParam(p: any, name: string, allowed?: string[]): string {
|
|||||||
|
|
||||||
export function integerParam(p: any, name: string): number {
|
export function integerParam(p: any, name: string): number {
|
||||||
if (typeof p === 'number') { return Math.floor(p); }
|
if (typeof p === 'number') { return Math.floor(p); }
|
||||||
if (typeof p === 'string') { return parseInt(p, 10); }
|
if (typeof p === 'string') {
|
||||||
throw new Error(`${name} parameter should be an integer: ${p}`);
|
const result = parseInt(p, 10);
|
||||||
|
if (isNaN(result)) {
|
||||||
|
throw new ApiError(`${name} parameter cannot be understood as an integer: ${p}`, 400);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
throw new ApiError(`${name} parameter should be an integer: ${p}`, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function optIntegerParam(p: any): number|undefined {
|
export function optIntegerParam(p: any): number|undefined {
|
||||||
|
@ -1511,11 +1511,11 @@ function testDocApi() {
|
|||||||
let resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/attachments/22`, chimpy);
|
let resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/attachments/22`, chimpy);
|
||||||
checkError(404, /Attachment not found: 22/, resp);
|
checkError(404, /Attachment not found: 22/, resp);
|
||||||
resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/attachments/moo`, chimpy);
|
resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/attachments/moo`, chimpy);
|
||||||
checkError(404, /Attachment not found: moo/, resp);
|
checkError(400, /parameter cannot be understood as an integer: moo/, resp);
|
||||||
resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/attachments/22/download`, chimpy);
|
resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/attachments/22/download`, chimpy);
|
||||||
checkError(404, /Attachment not found: 22/, resp);
|
checkError(404, /Attachment not found: 22/, resp);
|
||||||
resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/attachments/moo/download`, chimpy);
|
resp = await axios.get(`${serverUrl}/api/docs/${docIds.TestDoc}/attachments/moo/download`, chimpy);
|
||||||
checkError(404, /Attachment not found: moo/, resp);
|
checkError(400, /parameter cannot be understood as an integer: moo/, resp);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("POST /docs/{did}/attachments produces reasonable errors", async function() {
|
it("POST /docs/{did}/attachments produces reasonable errors", async function() {
|
||||||
|
Loading…
Reference in New Issue
Block a user