(core) support locking document structure to be controlled by owners only

Summary:
This is an incremental step in granular access control.  Using
a temporary `{colIds: '~o structure'}` representation in the
`_grist_ACLResources` table, the document structure can be set
to be controlled by owners only.

Test Plan: added test

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2613
This commit is contained in:
Paul Fitzpatrick 2020-09-17 07:34:54 -04:00
parent 2087ae5f67
commit b44e4a94ab
2 changed files with 38 additions and 18 deletions

View File

@ -525,7 +525,7 @@ export class ActiveDoc extends EventEmitter {
// Check if user has rights to download this doc. // Check if user has rights to download this doc.
public canDownload(docSession: OptDocSession) { public canDownload(docSession: OptDocSession) {
return this._granularAccess.hasViewAccess(docSession) && return this._granularAccess.hasViewAccess(docSession) &&
!this._granularAccess.hasNuancedAccess(docSession); this._granularAccess.canReadEverything(docSession);
} }
/** /**
@ -633,9 +633,9 @@ export class ActiveDoc extends EventEmitter {
*/ */
public async findColFromValues(docSession: DocSession, values: any[], n: number, public async findColFromValues(docSession: DocSession, values: any[], n: number,
optTableId?: string): Promise<number[]> { optTableId?: string): Promise<number[]> {
// This could leak information about private tables, so if there are any nuanced // This could leak information about private tables, so if user cannot read entire
// permissions in force and the user does not have full access, do nothing. // document, do nothing.
if (this._granularAccess.hasNuancedAccess(docSession)) { return []; } if (!this._granularAccess.canReadEverything(docSession)) { return []; }
this.logInfo(docSession, "findColFromValues(%s, %s, %s)", docSession, values, n); this.logInfo(docSession, "findColFromValues(%s, %s, %s)", docSession, values, n);
await this.waitForInitialization(); await this.waitForInitialization();
return this._dataEngine.pyCall('find_col_from_values', values, n, optTableId); return this._dataEngine.pyCall('find_col_from_values', values, n, optTableId);
@ -783,7 +783,7 @@ export class ActiveDoc extends EventEmitter {
public async autocomplete(docSession: DocSession, txt: string, tableId: string): Promise<string[]> { public async autocomplete(docSession: DocSession, txt: string, tableId: string): Promise<string[]> {
// Autocompletion can leak names of tables and columns. // Autocompletion can leak names of tables and columns.
if (this._granularAccess.hasNuancedAccess(docSession)) { return []; } if (!this._granularAccess.canReadEverything(docSession)) { return []; }
await this.waitForInitialization(); await this.waitForInitialization();
return this._dataEngine.pyCall('autocomplete', txt, tableId); return this._dataEngine.pyCall('autocomplete', txt, tableId);
} }
@ -828,7 +828,7 @@ export class ActiveDoc extends EventEmitter {
* ID for the fork. TODO: reconcile the two ways there are now of preparing a fork. * ID for the fork. TODO: reconcile the two ways there are now of preparing a fork.
*/ */
public async fork(docSession: DocSession): Promise<ForkResult> { public async fork(docSession: DocSession): Promise<ForkResult> {
if (this._granularAccess.hasNuancedAccess(docSession)) { if (!this._granularAccess.canReadEverything(docSession)) {
throw new Error('cannot confirm authority to copy document'); throw new Error('cannot confirm authority to copy document');
} }
const userId = docSession.client.getCachedUserId(); const userId = docSession.client.getCachedUserId();
@ -1183,7 +1183,7 @@ export class ActiveDoc extends EventEmitter {
actionGroup: ActionGroup, actionGroup: ActionGroup,
docActions: DocAction[] docActions: DocAction[]
}) { }) {
if (!this._granularAccess.hasNuancedAccess(docSession)) { return message; } if (this._granularAccess.canReadEverything(docSession)) { return message; }
const result = { const result = {
actionGroup: this._granularAccess.filterActionGroup(docSession, message.actionGroup), actionGroup: this._granularAccess.filterActionGroup(docSession, message.actionGroup),
docActions: this._granularAccess.filterOutgoingDocActions(docSession, message.docActions), docActions: this._granularAccess.filterOutgoingDocActions(docSession, message.docActions),

View File

@ -47,18 +47,24 @@ const SURPRISING_ACTIONS = new Set(['AddUser',
const OK_ACTIONS = new Set(['Calculate', 'AddEmptyTable']); const OK_ACTIONS = new Set(['Calculate', 'AddEmptyTable']);
/** /**
* Manage granular access to a document. This allows nuances other than the coarse
* owners/editors/viewers distinctions.
* *
* Currently the only supported nuance is to mark certain tables as accessible by * Manage granular access to a document. This allows nuances other than the coarse
* owners only. To do so, in the _grist_ACLResources table, add a row like the * owners/editors/viewers distinctions. As a placeholder for a future representation,
* one already there, but with "~o" as the colIds, and the desired tableId set. * nuances are stored in the _grist_ACLResources table. Supported nauances:
* This is just a placeholder for a future representation. *
* - {tableId, colIds: '~o'}: mark specified table as accessible by owners only.
* - {tableId: '', colIds: '~o structure'}: mark doc structure as editable by owners only.
*
*/ */
export class GranularAccess { export class GranularAccess {
private _resources: TableData; private _resources: TableData;
// Tables marked as accessible only by owners.
private _ownerOnlyTableIds = new Set<string>(); private _ownerOnlyTableIds = new Set<string>();
// Document structure modifiable only by owners?
private _onlyOwnersCanModifyStructure: boolean = false;
public constructor(private _docData: DocData) { public constructor(private _docData: DocData) {
this.update(); this.update();
} }
@ -70,9 +76,13 @@ export class GranularAccess {
this._resources = this._docData.getTable('_grist_ACLResources')!; this._resources = this._docData.getTable('_grist_ACLResources')!;
this._ownerOnlyTableIds.clear(); this._ownerOnlyTableIds.clear();
for (const res of this._resources.getRecords()) { for (const res of this._resources.getRecords()) {
if (res.tableId && String(res.colIds).startsWith('~o')) { const code = String(res.colIds);
if (res.tableId && code === '~o') {
this._ownerOnlyTableIds.add(String(res.tableId)); this._ownerOnlyTableIds.add(String(res.tableId));
} }
if (!res.tableId && code === '~o structure') {
this._onlyOwnersCanModifyStructure = true;
}
} }
} }
@ -115,7 +125,7 @@ export class GranularAccess {
* to filter acceptible parts of ActionGroup, rather than denying entirely. * to filter acceptible parts of ActionGroup, rather than denying entirely.
*/ */
public allowActionGroup(docSession: OptDocSession, actionGroup: ActionGroup): boolean { public allowActionGroup(docSession: OptDocSession, actionGroup: ActionGroup): boolean {
return !this.hasNuancedAccess(docSession); return this.canReadEverything(docSession);
} }
/** /**
@ -169,10 +179,20 @@ export class GranularAccess {
* access is simple and without nuance. * access is simple and without nuance.
*/ */
public hasNuancedAccess(docSession: OptDocSession): boolean { public hasNuancedAccess(docSession: OptDocSession): boolean {
if (this._ownerOnlyTableIds.size === 0) { return false; } if (this._ownerOnlyTableIds.size === 0 && !this._onlyOwnersCanModifyStructure) {
return false;
}
return !this.hasFullAccess(docSession); return !this.hasFullAccess(docSession);
} }
/**
* Check whether user can read everything in document.
*/
public canReadEverything(docSession: OptDocSession): boolean {
if (this._ownerOnlyTableIds.size === 0) { return true; }
return this.hasFullAccess(docSession);
}
/** /**
* Check whether user has owner-level access to the document. * Check whether user has owner-level access to the document.
*/ */
@ -210,8 +230,8 @@ export class GranularAccess {
*/ */
public filterMetaTables(docSession: OptDocSession, public filterMetaTables(docSession: OptDocSession,
tables: {[key: string]: TableDataAction}): {[key: string]: TableDataAction} { tables: {[key: string]: TableDataAction}): {[key: string]: TableDataAction} {
// If there are no nuances, return immediately. // If user has right to read everything, return immediately.
if (!this.hasNuancedAccess(docSession)) { return tables; } if (this.canReadEverything(docSession)) { return tables; }
// If we are going to modify metadata, make a copy. // If we are going to modify metadata, make a copy.
tables = JSON.parse(JSON.stringify(tables)); tables = JSON.parse(JSON.stringify(tables));
// Collect a list of all tables (by tableRef) to which the user has no access. // Collect a list of all tables (by tableRef) to which the user has no access.