(core) add initial support for special shares

Summary:
This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules.

It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with:
 * When reading rules, if there are shares, extra rules are added.
 * If there are shares, all rules are made conditional on a "ShareRef" user property.
 * "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share.

There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do:

```
gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}])
```

If you look at the home db now there should be something in the `shares` table:

```
$ sqlite3 -table landing.db "select * from shares"
+----+------------------------+------------------------+--------------+---------+
| id |          key           |         doc_id         |   link_id    | options |
+----+------------------------+------------------------+--------------+---------+
| 1  | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ...      |
+----+------------------------+------------------------+--------------+---------+
```

If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share.

E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc.

To actually share some material - useful commands:

```
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}])
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}])
```

For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options.

I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard.

Test Plan: tests added

Reviewers: dsagal, georgegevoian

Reviewed By: dsagal, georgegevoian

Subscribers: jarek, dsagal

Differential Revision: https://phab.getgrist.com/D4144
dependabot/npm_and_yarn/follow-redirects-1.15.4
Paul Fitzpatrick 4 months ago
parent f079d4b340
commit 2a206dfcf8

@ -27,7 +27,7 @@ import * as dispose from 'app/client/lib/dispose';
import * as log from 'app/client/lib/log';
import {CommRequest, CommResponse, CommResponseBase, CommResponseError, ValidEvent} from 'app/common/CommTypes';
import {UserAction} from 'app/common/DocActions';
import {DocListAPI, OpenLocalDocResult} from 'app/common/DocListAPI';
import {DocListAPI, OpenDocOptions, OpenLocalDocResult} from 'app/common/DocListAPI';
import {GristServerAPI} from 'app/common/GristServerAPI';
import {getInitialDocAssignment} from 'app/common/urlUtils';
import {Events as BackboneEvents} from 'backbone';
@ -149,9 +149,8 @@ export class Comm extends dispose.Disposable implements GristServerAPI, DocListA
* committed to a document that is called in hosted Grist - all other methods
* are called via DocComm.
*/
public async openDoc(docName: string, mode?: string,
linkParameters?: Record<string, string>): Promise<OpenLocalDocResult> {
return this._makeRequest(null, docName, 'openDoc', docName, mode, linkParameters);
public async openDoc(docName: string, options?: OpenDocOptions): Promise<OpenLocalDocResult> {
return this._makeRequest(null, docName, 'openDoc', docName, options);
}
/**

@ -17,7 +17,7 @@ import {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018
import {confirmModal} from 'app/client/ui2018/modals';
import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
import {delay} from 'app/common/delay';
import {OpenDocMode, UserOverride} from 'app/common/DocListAPI';
import {OpenDocMode, OpenDocOptions, UserOverride} from 'app/common/DocListAPI';
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
import {Product} from 'app/common/Features';
import {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
@ -182,8 +182,13 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
if (!urlId) {
this._openerHolder.clear();
} else {
FlowRunner.create(this._openerHolder,
(flow: AsyncFlow) => this._openDoc(flow, urlId, urlOpenMode, state.params?.compare, linkParameters)
FlowRunner.create(
this._openerHolder,
(flow: AsyncFlow) => this._openDoc(flow, urlId, {
openMode: urlOpenMode,
linkParameters,
originalUrlId: state.doc,
}, state.params?.compare)
)
.resultPromise.catch(err => this._onOpenError(err));
}
@ -325,9 +330,9 @@ It also disables formulas. [{{error}}]", {error: err.message})
this.offerRecovery(err);
}
private async _openDoc(flow: AsyncFlow, urlId: string, urlOpenMode: OpenDocMode | undefined,
comparisonUrlId: string | undefined,
linkParameters: Record<string, string> | undefined): Promise<void> {
private async _openDoc(flow: AsyncFlow, urlId: string, options: OpenDocOptions,
comparisonUrlId: string | undefined): Promise<void> {
const {openMode: urlOpenMode, linkParameters} = options;
console.log(`DocPageModel _openDoc starting for ${urlId} (mode ${urlOpenMode})` +
(comparisonUrlId ? ` (compare ${comparisonUrlId})` : ''));
const gristDocModulePromise = loadGristDoc();
@ -383,7 +388,11 @@ It also disables formulas. [{{error}}]", {error: err.message})
comm.useDocConnection(doc.id);
flow.onDispose(() => comm.releaseDocConnection(doc.id));
const openDocResponse = await comm.openDoc(doc.id, doc.openMode, linkParameters);
const openDocResponse = await comm.openDoc(doc.id, {
openMode: doc.openMode,
linkParameters,
originalUrlId: options.originalUrlId,
});
if (openDocResponse.recoveryMode || openDocResponse.userOverride) {
doc.isRecoveryMode = Boolean(openDocResponse.recoveryMode);
doc.userOverride = openDocResponse.userOverride || null;
@ -493,7 +502,6 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo {
const isPreFork = openMode === 'fork';
const isTemplate = doc.type === 'template' && (isFork || isPreFork);
const isEditable = !isSnapshot && (canEdit(doc.access) || isPreFork);
return {
...doc,
isFork,

@ -1,8 +1,10 @@
import {parsePermissions, permissionSetToText, splitSchemaEditPermissionSet} from 'app/common/ACLPermissions';
import {ACLShareRules, TableWithOverlay} from 'app/common/ACLShareRules';
import {AclRuleProblem} from 'app/common/ActiveDocAPI';
import {DocData} from 'app/common/DocData';
import {AclMatchFunc, ParsedAclFormula, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause';
import {getSetMapValue, isNonNullish} from 'app/common/gutil';
import {ShareOptions} from 'app/common/ShareOptions';
import {MetaRowRecord} from 'app/common/TableData';
import {decodeObject} from 'app/plugin/objtypes';
import sortBy = require('lodash/sortBy');
@ -347,7 +349,9 @@ export class ACLRuleCollection {
const names: string[] = [];
for (const rule of this.getUserAttributeRules().values()) {
const tableRef = tablesTable.findRow('tableId', rule.tableId);
const colRef = columnsTable.findMatchingRowId({parentId: tableRef, colId: rule.lookupColId});
const colRef = columnsTable.findMatchingRowId({
parentId: tableRef, colId: rule.lookupColId,
});
if (!colRef) {
invalidUAColumns.push(`${rule.tableId}.${rule.lookupColId}`);
names.push(rule.name);
@ -379,11 +383,13 @@ export class ACLRuleCollection {
export interface ReadAclOptions {
log: ILogger; // For logging warnings during rule processing.
compile?: (parsed: ParsedAclFormula) => AclMatchFunc;
// If true, call addHelperCols to add helper columns of restricted columns to rule sets.
// Used in the server for extra filtering, but not in the client, because:
// 1. They would show in the UI
// 2. They would be saved back after editing, causing them to accumulate
includeHelperCols?: boolean;
// If true, add and modify access rules in some special ways.
// Specifically, call addHelperCols to add helper columns of restricted columns to rule sets,
// and use ACLShareRules to implement any special shares as access rules.
// Used in the server, but not in the client, because of at least the following:
// 1. Rules would show in the UI
// 2. Rules would be saved back after editing, causing them to accumulate
enrichRulesForImplementation?: boolean;
// If true, rules with 'schemaEdit' permission are moved out of the '*:*' resource into a
// fictitious '*SPECIAL:SchemaEdit' resource. This is used only on the client, to present
@ -455,13 +461,32 @@ function getHelperCols(docData: DocData, tableId: string, colIds: string[], log:
* Parse all ACL rules in the document from DocData into a list of RuleSets and of
* UserAttributeRules. This is used by both client-side code and server-side.
*/
function readAclRules(docData: DocData, {log, compile, includeHelperCols}: ReadAclOptions): ReadAclResults {
const resourcesTable = docData.getMetaTable('_grist_ACLResources');
const rulesTable = docData.getMetaTable('_grist_ACLRules');
function readAclRules(docData: DocData, {log, compile, enrichRulesForImplementation}: ReadAclOptions): ReadAclResults {
// Wrap resources and rules tables so we can have "virtual" rules
// to implement special shares.
const resourcesTable = new TableWithOverlay(docData.getMetaTable('_grist_ACLResources'));
const rulesTable = new TableWithOverlay(docData.getMetaTable('_grist_ACLRules'));
const sharesTable = docData.getMetaTable('_grist_Shares');
const ruleSets: RuleSet[] = [];
const userAttributes: UserAttributeRule[] = [];
let hasShares: boolean = false;
const shares = sharesTable.getRecords();
// ACLShareRules is used to edit resourcesTable and rulesTable in place.
const shareRules = new ACLShareRules(docData, resourcesTable, rulesTable);
// Add virtual rules to implement shares, if there are any.
// Add the virtual rules only when implementing/interpreting them, as
// opposed to accessing them for presentation or manipulation in the UI.
if (enrichRulesForImplementation && shares.length > 0) {
for (const share of shares) {
const options: ShareOptions = JSON.parse(share.options || '{}');
shareRules.addRulesForShare(share.id, options);
}
shareRules.addDefaultRulesForShares();
hasShares = true;
}
// Group rules by resource first, ordering by rulePos. Each group will become a RuleSet.
const rulesByResource = new Map<number, Array<MetaRowRecord<'_grist_ACLRules'>>>();
for (const ruleRecord of sortBy(rulesTable.getRecords(), 'rulePos')) {
@ -472,7 +497,6 @@ function readAclRules(docData: DocData, {log, compile, includeHelperCols}: ReadA
const resourceRec = resourcesTable.getRecord(resourceId);
if (!resourceRec) {
throw new Error(`ACLRule ${rules[0].id} refers to an invalid ACLResource ${resourceId}`);
continue;
}
if (!resourceRec.tableId || !resourceRec.colIds) {
// This should only be the case for the old-style default rule/resource, which we
@ -482,7 +506,7 @@ function readAclRules(docData: DocData, {log, compile, includeHelperCols}: ReadA
const tableId = resourceRec.tableId;
const colIds = resourceRec.colIds === '*' ? '*' : resourceRec.colIds.split(',');
if (includeHelperCols && Array.isArray(colIds)) {
if (enrichRulesForImplementation && Array.isArray(colIds)) {
colIds.push(...getHelperCols(docData, tableId, colIds, log));
}
@ -506,7 +530,13 @@ function readAclRules(docData: DocData, {log, compile, includeHelperCols}: ReadA
} else if (rule.aclFormula && !rule.aclFormulaParsed) {
throw new Error(`ACLRule ${rule.id} invalid because missing its parsed formula`);
} else {
const aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed));
let aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed));
// If we have "virtual" rules to implement shares, then regular
// rules need to be tweaked so that they don't apply when the
// share is active.
if (hasShares && rule.id >= 0) {
aclFormulaParsed = shareRules.transformNonShareRules({rule, aclFormulaParsed});
}
body.push({
origRecord: rule,
aclFormula: String(rule.aclFormula),

@ -0,0 +1,296 @@
import { DocData } from 'app/common/DocData';
import { SchemaTypes } from 'app/common/schema';
import { ShareOptions } from 'app/common/ShareOptions';
import { MetaRowRecord, MetaTableData } from 'app/common/TableData';
import { isEqual } from 'lodash';
/**
* For special shares, we need to refer to resources that may not
* be listed in the _grist_ACLResources table, and have rules that
* aren't backed by storage in _grist_ACLRules. So we implement
* a small helper to add an overlay of extra resources and rules.
* They are distinguishable from real, stored resources and rules
* by having negative IDs.
*/
export class TableWithOverlay<T extends keyof SchemaTypes> {
private _extraRecords = new Array<MetaRowRecord<T>>();
private _extraRecordsById = new Map<number, MetaRowRecord<T>>();
private _nextFreeVirtualId: number = -1;
public constructor(private _originalTable: MetaTableData<T>) {}
// Add a record to the table, but only as an overlay - no
// persistent changes are made. Uses negative row IDs.
// Returns the ID assigned to the record. The passed in
// record is expected to have an ID of zero.
public addRecord(rec: MetaRowRecord<T>): number {
if (rec.id !== 0) { throw new Error('Expected a zero ID'); }
const id = this._nextFreeVirtualId;
const recWithCorrectId: MetaRowRecord<T> = {...rec, id};
this._extraRecords.push({...rec, id});
this._extraRecordsById.set(id, recWithCorrectId);
this._nextFreeVirtualId--;
return id;
}
// Support the few MetaTableData methods we actually use
// in ACLRuleCollection and ACLShareRules.
public getRecord(resourceId: number) {
// Reroute negative IDs to our local stash of records.
if (resourceId < 0) {
return this._extraRecordsById.get(resourceId);
}
// Everything else, we just pass along.
return this._originalTable.getRecord(resourceId);
}
public getRecords() {
return [...this._originalTable.getRecords(), ...this._extraRecords];
}
public findMatchingRowId(properties: Partial<MetaRowRecord<T>>): number {
// Check stored records.
const rowId = this._originalTable.findMatchingRowId(properties);
if (rowId) { return rowId; }
// Check overlay.
return this._extraRecords.find((rec) =>
Object.keys(properties).every((p) => isEqual(
(rec as any)[p],
(properties as any)[p])))?.id || 0;
}
}
/**
* Helper for managing special share rules.
*/
export class ACLShareRules {
public constructor(
public docData: DocData,
public resourcesTable: TableWithOverlay<'_grist_ACLResources'>,
public rulesTable: TableWithOverlay<'_grist_ACLRules'>,
) {}
/**
* Add any rules needed for the specified share.
*
* The only kind of share we support for now is form endpoint
* sharing.
*/
public addRulesForShare(shareRef: number, shareOptions: ShareOptions) {
// TODO: Unpublished shares could and should be blocked earlier,
// by home server
if (!shareOptions.publish) {
this._blockShare(shareRef);
return;
}
// Let's go looking for sections related to the share.
// It was decided that the relationship between sections and
// shares is via pages. Every section on a given page can belong
// to at most one share.
// Ignore sections which do not have `publish` set to `true` in
// `shareOptions`.
const pages = this.docData.getMetaTable('_grist_Pages').filterRecords({
shareRef,
});
const parentViews = new Set(pages.map(page => page.viewRef));
const sections = this.docData.getMetaTable('_grist_Views_section').getRecords().filter(
section => {
if (!parentViews.has(section.parentId)) { return false; }
const options = JSON.parse(section.shareOptions || '{}');
return Boolean(options.publish) && Boolean(options.form);
}
);
const tableRefs = new Set(sections.map(section => section.tableRef));
const tables = this.docData.getMetaTable('_grist_Tables').getRecords().filter(
table => tableRefs.has(table.id)
);
// For tables associated with forms, allow creation of records,
// and reading of referenced columns.
// TODO: should probably be limiting to a set of columns associated
// with section - but for form widget that could potentially be very
// confusing since it may not be easy to see that certain columns
// haven't been made visible for it? For now, just working at table
// level.
for (const table of tables) {
this._shareTableForForm(table, shareRef);
}
}
/**
* When accessing a document via a share, by default no user tables are
* accessible. Everything added to the share gives additional
* access, and never reduces access, making it easy to grant
* access to multiple parts of the document.
*
* We do leave access unchanged for metadata tables, since they are
* censored via an alternative mechanism.
*/
public addDefaultRulesForShares() {
const tableIds = this.docData.getMetaTable('_grist_Tables').getRecords()
.map(table => table.tableId)
.filter(tableId => !tableId.startsWith('_grist_'))
.sort();
for (const tableId of tableIds) {
const resource = this._findOrAddResource({
tableId, colIds: '*',
});
const aclFormula = `user.ShareRef is not None`;
const aclFormulaParsed = JSON.stringify([
'NotEq',
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
['Const', null] ]);
this.rulesTable.addRecord(this._makeRule({
resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS',
}));
}
}
/**
* When accessing a document via a share, any regular granular access
* rules should not apply. This requires an extra conditional.
*/
public transformNonShareRules(state: {
rule: MetaRowRecord<'_grist_ACLRules'>,
aclFormulaParsed: object,
}) {
state.rule.aclFormula = 'user.ShareRef is None and (' + String(state.rule.aclFormula || 'True') + ')';
state.aclFormulaParsed = [
'And',
[ 'Eq', [ 'Attr', [ 'Name', 'user' ], 'ShareRef' ], ['Const', null] ],
state.aclFormulaParsed || [ 'Const', true ]
];
state.rule.aclFormulaParsed = JSON.stringify(state.aclFormulaParsed);
return state.aclFormulaParsed;
}
/**
* Allow creating records in a table.
*/
private _shareTableForForm(table: MetaRowRecord<'_grist_Tables'>,
shareRef: number) {
const resource = this._findOrAddResource({
tableId: table.tableId,
colIds: '*',
});
let aclFormula = `user.ShareRef == ${shareRef}`;
let aclFormulaParsed = JSON.stringify([
'Eq',
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
[ 'Const', shareRef ] ]);
this.rulesTable.addRecord(this._makeRule({
resource, aclFormula, aclFormulaParsed, permissionsText: '+C',
}));
// This is a hack to grant read schema access, needed for forms -
// Should not be needed once forms are actually available, but
// until them is very handy to allow using the web client to
// submit records.
aclFormula = `user.ShareRef == ${shareRef} and rec.id == 0`;
aclFormulaParsed = JSON.stringify(
[ 'And',
[ 'Eq',
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
['Const', shareRef] ],
[ 'Eq', [ 'Attr', ['Name', 'rec'], 'id'], ['Const', 0]]]);
this.rulesTable.addRecord(this._makeRule({
resource, aclFormula, aclFormulaParsed, permissionsText: '+R',
}));
this._shareTableReferencesForForm(table, shareRef);
}
/**
* Give read access to referenced columns.
*/
private _shareTableReferencesForForm(table: MetaRowRecord<'_grist_Tables'>,
shareRef: number) {
const tables = this.docData.getMetaTable('_grist_Tables');
const columns = this.docData.getMetaTable('_grist_Tables_column');
const tableColumns = columns.filterRecords({
parentId: table.id,
}).filter(c => c.type.startsWith('Ref:') || c.type.startsWith('RefList:'));
for (const column of tableColumns) {
const visibleColRef = column.visibleCol;
// This could be blank in tests, not sure about real life.
if (!visibleColRef) { continue; }
const visibleCol = columns.getRecord(visibleColRef);
if (!visibleCol) { continue; }
const referencedTable = tables.getRecord(visibleCol.parentId);
if (!referencedTable) { continue; }
const tableId = referencedTable.tableId;
const colId = visibleCol.colId;
const resource = this._findOrAddResource({
tableId: tableId,
colIds: colId,
});
const aclFormula = `user.ShareRef == ${shareRef}`;
const aclFormulaParsed = JSON.stringify(
[ 'Eq',
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
['Const', shareRef] ]);
this.rulesTable.addRecord(this._makeRule({
resource, aclFormula, aclFormulaParsed, permissionsText: '+R',
}));
}
}
/**
* Find a resource we need, and return its rowId. The resource is
* added if it is not already present.
*/
private _findOrAddResource(properties: {
tableId: string,
colIds: string,
}): number {
const resource = this.resourcesTable.findMatchingRowId(properties);
if (resource !== 0) { return resource; }
return this.resourcesTable.addRecord({
id: 0,
...properties,
});
}
private _blockShare(shareRef: number) {
const resource = this._findOrAddResource({
tableId: '*', colIds: '*',
});
const aclFormula = `user.ShareRef == ${shareRef}`;
const aclFormulaParsed = JSON.stringify(
[ 'Eq',
[ 'Attr', [ "Name", "user" ], "ShareRef" ],
['Const', shareRef] ]);
this.rulesTable.addRecord(this._makeRule({
resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS',
}));
}
private _makeRule(options: {
resource: number,
aclFormula: string,
aclFormulaParsed: string,
permissionsText: string,
}): MetaRowRecord<'_grist_ACLRules'> {
const {resource, aclFormula, aclFormulaParsed, permissionsText} = options;
return {
id: 0,
resource,
aclFormula,
aclFormulaParsed,
memo: '',
permissionsText,
userAttributes: '',
rulePos: 0,
// The following fields are unused and deprecated.
aclColumn: 0,
permissions: 0,
principals: '',
};
}
}

@ -16,6 +16,37 @@ export const OpenDocMode = StringUnion(
);
export type OpenDocMode = typeof OpenDocMode.type;
/**
* A collection of options for opening documents on behalf of
* a user in special circumstances we have accumulated. This is
* specifically for the Grist front end, when setting up a websocket
* connection to a doc worker willing to serve a document. Remember
* that the front end is untrusted and any information it passes should
* be treated as user input.
*/
export interface OpenDocOptions {
/**
* Users may now access a single specific document (with a given docId)
* using distinct keys for which different access rules apply. When opening
* a document, the ID used in the URL may now be passed along so
* that the back-end can grant appropriate access.
*/
originalUrlId?: string;
/**
* Access to a document by a user may be voluntarily limited to
* read-only, or to trigger forking on edits.
*/
openMode?: OpenDocMode;
/**
* Access to a document may be modulated by URL parameters.
* These parameters become an attribute of the user, for
* access control.
*/
linkParameters?: Record<string, string>;
}
/**
* Represents an entry in the DocList.
*/
@ -89,6 +120,5 @@ export interface DocListAPI {
/**
* Opens a document, loads it, subscribes to its userAction events, and returns its metadata.
*/
openDoc(userDocName: string, openMode?: OpenDocMode,
linkParameters?: Record<string, string>): Promise<OpenLocalDocResult>;
openDoc(userDocName: string, options?: OpenDocOptions): Promise<OpenLocalDocResult>;
}

@ -47,6 +47,8 @@ export interface UserInfo {
UserID: number | null;
UserRef: string | null;
SessionID: string | null;
ShareRef: number | null; // This is a rowId in the _grist_Shares table, if the user
// is accessing a document via a share. Otherwise null.
[attributes: string]: unknown;
toJSON(): {[key: string]: any};
}

@ -0,0 +1,22 @@
/**
*
* Options on a share, or a shared widget. This is mostly
* a placeholder currently. The same structure is currently
* used both for shares and for specific shared widgets, but
* this is just to save a little time right now, and should
* not be preserved in future work.
*
* The only flag that matter today is "publish".
* The "access" flag could be stripped for now without consequences.
*
*/
export interface ShareOptions {
// A share or widget that does not have publish set to true
// will not be available via the share mechanism.
publish?: boolean;
// Can be set to 'viewers' to label the share as readonly.
// Half-baked, just here to exercise an aspect of homedb
// syncing.
access?: 'editors' | 'viewers';
}

@ -1,7 +1,7 @@
import {BillingPage, BillingSubPage, BillingTask} from 'app/common/BillingAPI';
import {OpenDocMode} from 'app/common/DocListAPI';
import {EngineCode} from 'app/common/DocumentSettings';
import {encodeQueryParams, isAffirmative} from 'app/common/gutil';
import {encodeQueryParams, isAffirmative, removePrefix} from 'app/common/gutil';
import {LocalPlugin} from 'app/common/plugin';
import {StringUnion} from 'app/common/StringUnion';
import {TelemetryLevel} from 'app/common/Telemetry';
@ -57,6 +57,10 @@ export const DEFAULT_HOME_SUBDOMAIN = 'api';
// as a prefix of the docId.
export const MIN_URLID_PREFIX_LENGTH = 12;
// A prefix that identifies a urlId as a share key.
// Important that this not be part of a valid docId.
export const SHARE_KEY_PREFIX = 's.';
/**
* Special ways to open a document, based on what the user intends to do.
* - view: Open document in read-only mode (even if user has edit rights)
@ -140,6 +144,7 @@ export interface IGristUrlState {
api?: boolean; // indicates that the URL should be encoded as an API URL, not as a landing page.
// But this barely works, and is suitable only for documents. For decoding it
// indicates that the URL probably points to an API endpoint.
viaShare?: boolean; // Accessing document via a special share.
}
// Subset of GristLoadConfig used by getOrgUrlInfo(), which affects the interpretation of the
@ -253,6 +258,13 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
if (state.doc) {
if (state.api) {
parts.push(`docs/${encodeURIComponent(state.doc)}`);
} else if (state.viaShare) {
// Use a special path, and remove SHARE_KEY_PREFIX from id.
let id = state.doc;
if (id.startsWith(SHARE_KEY_PREFIX)) {
id = id.substring(SHARE_KEY_PREFIX.length);
}
parts.push(`s/${encodeURIComponent(id)}`);
} else if (state.slug) {
parts.push(`${encodeURIComponent(state.doc)}/${encodeURIComponent(state.slug)}`);
} else {
@ -366,6 +378,13 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
map.set('doc', map.get('docs')!);
}
// /s/<key> is accepted as another way to write -> /doc/<share-prefix><key>
if (map.has('s')) {
const key = map.get('s');
map.set('doc', `${SHARE_KEY_PREFIX}${key}`);
state.viaShare = true;
}
// When the urlId is a prefix of the docId, documents are identified
// as "<urlId>/slug" instead of "doc/<urlId>". We can detect that because
// the minimum length of a urlId prefix is longer than the maximum length
@ -919,33 +938,41 @@ export function parseFirstUrlPart(tag: string, path: string): {value?: string, p
}
/**
* The internal structure of a UrlId. There is no internal structure. unless the id is
* for a fork, in which case the fork has a separate id, and a user id may also be
* embedded to track ownership.
* The internal structure of a UrlId. There is no internal structure,
* except in the following cases. The id may be for a fork, in which
* case the fork has a separate id, and a user id may also be embedded
* to track ownership. The id may be a share key, in which case it
* has some special syntax to identify it as so.
*/
export interface UrlIdParts {
trunkId: string;
forkId?: string;
forkUserId?: number;
snapshotId?: string;
shareKey?: string;
}
// Parse a string of the form trunkId or trunkId~forkId or trunkId~forkId~forkUserId
// or trunkId[....]~v=snapshotId
// or <SHARE-KEY-PREFIX>shareKey
export function parseUrlId(urlId: string): UrlIdParts {
let snapshotId: string|undefined;
const parts = urlId.split('~');
const bareParts = parts.filter(part => !part.includes('='));
const bareParts = parts.filter(part => !part.includes('v='));
for (const part of parts) {
if (part.startsWith('v=')) {
snapshotId = decodeURIComponent(part.substr(2).replace(/_/g, '%'));
}
}
const trunkId = bareParts[0];
// IDs starting with SHARE_KEY_PREFIX are in fact shares.
const shareKey = removePrefix(trunkId, SHARE_KEY_PREFIX) || undefined;
return {
trunkId: bareParts[0],
forkId: bareParts[1],
forkUserId: (bareParts[2] !== undefined) ? parseInt(bareParts[2], 10) : undefined,
snapshotId,
shareKey,
};
}
@ -984,7 +1011,7 @@ export interface HashLink {
// a candidate for use in prettier urls.
function shouldIncludeSlug(doc: {id: string, urlId: string|null}): boolean {
if (!doc.urlId || doc.urlId.length < MIN_URLID_PREFIX_LENGTH) { return false; }
return doc.id.startsWith(doc.urlId);
return doc.id.startsWith(doc.urlId) || doc.urlId.startsWith(SHARE_KEY_PREFIX);
}
// Convert the name of a document into a slug. Only alphanumerics are retained,

@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData";
// tslint:disable:object-literal-key-quotes
export const SCHEMA_VERSION = 40;
export const SCHEMA_VERSION = 41;
export const schema = {
@ -92,6 +92,7 @@ export const schema = {
viewRef : "Ref:_grist_Views",
indentation : "Int",
pagePos : "PositionNumber",
shareRef : "Ref:_grist_Shares",
},
"_grist_Views": {
@ -119,6 +120,7 @@ export const schema = {
linkTargetColRef : "Ref:_grist_Tables_column",
embedId : "Text",
rules : "RefList:_grist_Tables_column",
shareOptions : "Text",
},
"_grist_Views_section_field": {
@ -216,6 +218,13 @@ export const schema = {
userRef : "Text",
},
"_grist_Shares": {
linkId : "Text",
options : "Text",
label : "Text",
description : "Text",
},
};
export interface SchemaTypes {
@ -304,6 +313,7 @@ export interface SchemaTypes {
viewRef: number;
indentation: number;
pagePos: number;
shareRef: number;
};
"_grist_Views": {
@ -331,6 +341,7 @@ export interface SchemaTypes {
linkTargetColRef: number;
embedId: string;
rules: [GristObjCode.List, ...number[]]|null;
shareOptions: string;
};
"_grist_Views_section_field": {
@ -428,4 +439,11 @@ export interface SchemaTypes {
userRef: string;
};
"_grist_Shares": {
linkId: string;
options: string;
label: string;
description: string;
};
}

@ -40,6 +40,12 @@ export class Document extends Resource {
// fetching user has on the doc, i.e. 'owners', 'editors', 'viewers'
public access: Role|null;
// Property that may be returned when the doc is fetched to indicate the share it
// is being accessed with. The identifier used is the linkId, which is the share
// identifier that is the same between the home database and the document.
// The linkId is not a secret, and need only be unique within a document.
public linkId?: string|null;
// Property set for forks, containing access the fetching user has on the trunk.
public trunkAccess?: Role|null;

@ -0,0 +1,48 @@
import {ShareOptions} from 'app/common/ShareOptions';
import {Document} from 'app/gen-server/entity/Document';
import {nativeValues} from 'app/gen-server/lib/values';
import {BaseEntity, Column, Entity, JoinColumn, ManyToOne,
PrimaryColumn} from 'typeorm';
@Entity({name: 'shares'})
export class Share extends BaseEntity {
/**
* A simple integer auto-incrementing identifier for a share.
* Suitable for use in within-database references.
*/
@PrimaryColumn({name: 'id', type: Number})
public id: number;
/**
* A long string secret to identify the share. Suitable for URLs.
* Unique across the database / installation.
*/
@Column({name: 'key', type: String})
public key: string;
/**
* A string to identify the share. This identifier is common to the home
* database and the document specified by docId. It need only be unique
* within that document, and is not a secret. These two properties are
* important when you imagine handling documents that are transferred
* between installations, or copied, etc.
*/
@Column({name: 'link_id', type: String})
public linkId: string;
/**
* The document to which the share belongs.
*/
@Column({name: 'doc_id', type: String})
public docId: string;
/**
* Any overall qualifiers on the share.
*/
@Column({name: 'options', type: nativeValues.jsonEntityType})
public options: ShareOptions;
@ManyToOne(type => Document)
@JoinColumn({name: 'doc_id'})
public doc: Document;
}

@ -3,6 +3,7 @@ import fetch, { RequestInit } from 'node-fetch';
import {AbortController} from 'node-abort-controller';
import { ApiError } from 'app/common/ApiError';
import { SHARE_KEY_PREFIX } from 'app/common/gristUrls';
import { removeTrailingSlash } from 'app/common/gutil';
import { HomeDBManager } from "app/gen-server/lib/HomeDBManager";
import { assertAccess, getOrSetDocAuth, getTransitiveHeaders, RequestWithLogin } from 'app/server/lib/Authorizer';
@ -33,6 +34,13 @@ export class DocApiForwarder {
}
public addEndpoints(app: express.Application) {
app.use((req, res, next) => {
if (req.url.startsWith('/api/s/')) {
req.url = req.url.replace('/api/s/', `/api/docs/${SHARE_KEY_PREFIX}`);
}
next();
});
// Middleware to forward a request about an existing document that user has access to.
// We do not check whether the document has been soft-deleted; that will be checked by
// the worker if needed.

@ -36,6 +36,7 @@ import {AccessOption, AccessOptionWithRole, Organization} from "app/gen-server/e
import {Pref} from "app/gen-server/entity/Pref";
import {getDefaultProductNames, personalFreeFeatures, Product} from "app/gen-server/entity/Product";
import {Secret} from "app/gen-server/entity/Secret";
import {Share} from "app/gen-server/entity/Share";
import {User} from "app/gen-server/entity/User";
import {Workspace} from "app/gen-server/entity/Workspace";
import {Limit} from 'app/gen-server/entity/Limit';
@ -1194,10 +1195,41 @@ export class HomeDBManager extends EventEmitter {
// Doc permissions of forks are based on the "trunk" document, so make sure
// we look up permissions of trunk if we are on a fork (we'll fix the permissions
// up for the fork immediately afterwards).
const {trunkId, forkId, forkUserId, snapshotId} = parseUrlId(key.urlId);
const {trunkId, forkId, forkUserId, snapshotId,
shareKey} = parseUrlId(key.urlId);
let doc: Document;
if (shareKey) {
const res = await (transaction || this._connection).createQueryBuilder()
.select('shares')
.from(Share, 'shares')
.leftJoinAndSelect('shares.doc', 'doc')
.where('key = :key', {key: shareKey})
.getOne();
if (!res) {
throw new ApiError('Share not known', 404);
}
doc = {
name: res.doc?.name,
id: res.docId,
linkId: res.linkId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isPinned: false,
urlId: key.urlId,
// For the moment, I don't include a useful workspace.
// TODO: look up the document properly, perhaps delegating
// to the regular path through this method.
workspace: this.unwrapQueryResult<Workspace>(
await this.getWorkspace({userId: this.getSupportUserId()},
this._exampleWorkspaceId)),
aliases: [],
access: 'editors', // a share may have view/edit access,
// need to check at granular level
} as any;
return doc;
}
const urlId = trunkId;
if (forkId || snapshotId) { key = {...key, urlId}; }
let doc: Document;
if (urlId === NEW_DOCUMENT_CODE) {
if (!forkId) { throw new ApiError('invalid document identifier', 400); }
// We imagine current user owning trunk if there is no embedded userId, or
@ -3022,6 +3054,41 @@ export class HomeDBManager extends EventEmitter {
return limitOrError;
}
public async syncShares(docId: string, shares: ShareInfo[]) {
return this._connection.transaction(async manager => {
for (const share of shares) {
const key = makeId();
await manager.createQueryBuilder()
.insert()
// if urlId has been used before, update it
.onConflict(`(doc_id, link_id) DO UPDATE SET options = :options`)
.setParameter('options', share.options)
.into(Share)
.values({
linkId: share.linkId,
docId,
options: JSON.parse(share.options),
key,
})
.execute();
}
const dbShares = await manager.createQueryBuilder()
.select('shares')
.from(Share, 'shares')
.where('doc_id = :docId', {docId})
.getMany();
const activeLinkIds = new Set(shares.map(share => share.linkId));
const oldShares = dbShares.filter(share => !activeLinkIds.has(share.linkId));
if (oldShares.length > 0) {
await manager.createQueryBuilder()
.delete()
.from('shares')
.whereInIds(oldShares.map(share => share.id))
.execute();
}
});
}
private async _getOrCreateLimit(accountId: number, limitType: LimitType, force: boolean): Promise<Limit|null> {
if (accountId === 0) {
throw new Error(`getLimit: called for not existing account`);
@ -4866,3 +4933,8 @@ export function getDocAuthKeyFromScope(scope: Scope): DocAuthKey {
if (!urlId) { throw new Error('document required'); }
return {urlId, userId, org};
}
interface ShareInfo {
linkId: string;
options: string;
}

@ -0,0 +1,54 @@
import { nativeValues } from 'app/gen-server/lib/values';
import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableUnique } from 'typeorm';
export class Shares1701557445716 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(new Table({
name: 'shares',
columns: [
{
name: 'id',
type: 'integer',
isGenerated: true,
generationStrategy: 'increment',
isPrimary: true
},
{
name: 'key',
type: 'varchar',
isUnique: true,
},
{
name: 'doc_id',
type: 'varchar',
},
{
name: 'link_id',
type: 'varchar',
},
{
name: 'options',
type: nativeValues.jsonType,
},
]
}));
await queryRunner.createForeignKeys('shares', [
new TableForeignKey({
columnNames: ['doc_id'],
referencedTableName: 'docs',
referencedColumnNames: ['id'],
onDelete: 'CASCADE', // delete share if doc goes away
}),
]);
await queryRunner.createUniqueConstraints('shares', [
new TableUnique({
columnNames: ['doc_id', 'link_id'],
}),
]);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('shares');
}
}

@ -1832,6 +1832,22 @@ export class ActiveDoc extends EventEmitter {
));
}
public async syncShares(docSession: OptDocSession) {
const metaTables = await this.fetchMetaTables(docSession);
const shares = metaTables['_grist_Shares'];
const ids = shares[2];
const vals = shares[3];
const goodShares = ids.map((id, idx) => {
return {
id,
linkId: String(vals['linkId'][idx]),
options: String(vals['options'][idx]),
};
});
await this.getHomeDbManager()?.syncShares(this.docName, goodShares);
return goodShares;
}
/**
* Loads an open document from DocStorage. Returns a list of the tables it contains.
*/

@ -6,7 +6,7 @@
import * as express from 'express';
import {ApiError} from 'app/common/ApiError';
import {getSlugIfNeeded, parseUrlId} from 'app/common/gristUrls';
import {getSlugIfNeeded, parseUrlId, SHARE_KEY_PREFIX} from 'app/common/gristUrls';
import {LocalPlugin} from "app/common/plugin";
import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry';
import {Document as APIDocument} from 'app/common/UserAPI';
@ -105,7 +105,8 @@ export function attachAppEndpoint(options: AttachOptions): void {
const slug = getSlugIfNeeded(doc);
const slugMismatch = (req.params.slug || null) !== (slug || null);
const preferredUrlId = doc.urlId || doc.id;
if (urlId !== preferredUrlId || slugMismatch) {
if (!req.params.viaShare && // Don't bother canonicalizing for shares yet.
(urlId !== preferredUrlId || slugMismatch)) {
// Prepare to redirect to canonical url for document.
// Preserve any query parameters or fragments.
const queryOrFragmentCheck = req.originalUrl.match(/([#?].*)/);
@ -215,6 +216,14 @@ export function attachAppEndpoint(options: AttachOptions): void {
// The * is a wildcard in express 4, rather than a regex symbol.
// See https://expressjs.com/en/guide/routing.html
app.get('/doc/:urlId([^/]+):remainder(*)', ...docMiddleware, docHandler);
app.get('/s/:urlId([^/]+):remainder(*)',
(req, res, next) => {
// /s/<key> is another way of writing /doc/<prefix><key> for shares.
req.params.urlId = SHARE_KEY_PREFIX + req.params.urlId;
req.params.viaShare = "1";
next();
},
...docMiddleware, docHandler);
app.get('/:urlId([^-/]{12,})(/:slug([^/]+):remainder(*))?',
...docMiddleware, docHandler);
}

@ -13,7 +13,7 @@ import {
} from 'app/common/DocActions';
import {isRaisedException} from "app/common/gristTypes";
import {Box, RenderBox, RenderContext} from "app/common/Forms";
import {buildUrlId, parseUrlId} from "app/common/gristUrls";
import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil";
import {SchemaTypes} from "app/common/schema";
import {SortFunc} from 'app/common/SortFunc';
@ -178,6 +178,12 @@ export class DocWorkerApi {
* to apply to these routes.
*/
public addEndpoints() {
this._app.use((req, res, next) => {
if (req.url.startsWith('/api/s/')) {
req.url = req.url.replace('/api/s/', `/api/docs/${SHARE_KEY_PREFIX}`);
}
next();
});
// check document exists (not soft deleted) and user can view it
const canView = expressWrap(this._assertAccess.bind(this, 'viewers', false));

@ -8,8 +8,9 @@ import * as path from 'path';
import {ApiError} from 'app/common/ApiError';
import {mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate';
import {BrowserSettings} from 'app/common/BrowserSettings';
import {DocCreationInfo, DocEntry, DocListAPI, OpenDocMode, OpenLocalDocResult} from 'app/common/DocListAPI';
import {DocCreationInfo, DocEntry, DocListAPI, OpenDocOptions, OpenLocalDocResult} from 'app/common/DocListAPI';
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
import {parseUrlId} from 'app/common/gristUrls';
import {Invite} from 'app/common/sharing';
import {tbind} from 'app/common/tbind';
import {TelemetryMetadataByLevel} from 'app/common/Telemetry';
@ -298,8 +299,13 @@ export class DocManager extends EventEmitter {
* `doc` - the object with metadata tables.
*/
public async openDoc(client: Client, docId: string,
openMode: OpenDocMode = 'default',
linkParameters: Record<string, string> = {}): Promise<OpenLocalDocResult> {
options?: OpenDocOptions): Promise<OpenLocalDocResult> {
if (typeof options === 'string') {
throw new Error('openDoc call with outdated parameter type');
}
const openMode = options?.openMode || 'default';
const linkParameters = options?.linkParameters || {};
const originalUrlId = options?.originalUrlId;
let auth: Authorizer;
let userId: number | undefined;
const dbManager = this._homeDbManager;
@ -313,7 +319,12 @@ export class DocManager extends EventEmitter {
// We use docId in the key, and disallow urlId, so we can be sure that we are looking at the
// right doc when we re-query the DB over the life of the websocket.
const key = {urlId: docId, userId, org};
const useShareUrlId = Boolean(originalUrlId && parseUrlId(originalUrlId).shareKey);
const key = {
urlId: useShareUrlId ? originalUrlId! : docId,
userId,
org
};
log.debug("DocManager.openDoc Authorizer key", key);
const docAuth = await dbManager.getDocAuthCached(key);
assertAccess('viewers', docAuth);

@ -166,6 +166,16 @@ export function getDocSessionAccess(docSession: OptDocSession): Role {
throw new Error('getDocSessionAccess could not find access information in DocSession');
}
export function getDocSessionShare(docSession: OptDocSession): string|null {
if (docSession.authorizer) {
return docSession.authorizer.getCachedAuth().cachedDoc?.linkId || null;
}
if (docSession.req) {
return docSession.req.docAuth?.cachedDoc?.linkId || null;
}
return null;
}
export function getDocSessionAccessOrNull(docSession: OptDocSession): Role|null {
try {
return getDocSessionAccess(docSession);

@ -37,8 +37,8 @@ import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
import { GristObjCode } from 'app/plugin/GristData';
import { compileAclFormula } from 'app/server/lib/ACLFormula';
import { DocClients } from 'app/server/lib/DocClients';
import { getDocSessionAccess, getDocSessionAltSessionId, getDocSessionUser,
OptDocSession } from 'app/server/lib/DocSession';
import { getDocSessionAccess, getDocSessionAltSessionId, getDocSessionShare,
getDocSessionUser, OptDocSession } from 'app/server/lib/DocSession';
import { DocStorage, REMOVE_UNUSED_ATTACHMENTS_DELAY } from 'app/server/lib/DocStorage';
import log from 'app/server/lib/log';
import { IPermissionInfo, MixedPermissionSetWithContext,
@ -98,7 +98,8 @@ function isAddOrUpdateRecordAction([actionName]: UserAction): boolean {
// specifically _grist_Attachments.
const STRUCTURAL_TABLES = new Set(['_grist_Tables', '_grist_Tables_column', '_grist_Views',
'_grist_Views_section', '_grist_Views_section_field',
'_grist_ACLResources', '_grist_ACLRules']);
'_grist_ACLResources', '_grist_ACLRules',
'_grist_Shares']);
// Actions that won't be allowed (yet) for a user with nuanced access to a document.
// A few may be innocuous, but that hasn't been figured out yet.
@ -238,7 +239,8 @@ export interface GranularAccessForBundle {
* (the UserAction has been compiled to DocActions).
* - canApplyBundle(), called when DocActions have been produced from UserActions,
* but before those DocActions have been applied to the DB. If fails, the modification
* will be abandoned.
* will be abandoned. This method will also finalize some bundle state,
* specifically the `maybeHasShareChanges` flag.
* - appliedBundle(), called when DocActions have been applied to the DB, but before
* those changes have been sent to clients.
* - sendDocUpdateForBundle() is called once a bundle has been applied, to notify
@ -279,7 +281,9 @@ export class GranularAccess implements GranularAccessForBundle {
// Flag for whether doc actions mention a rule change, even if passive due to
// schema changes.
hasAnyRuleChange: boolean,
maybeHasShareChanges: boolean,
options: ApplyUAExtendedOptions|null,
shareRef?: number;
}|null;
public constructor(
@ -308,6 +312,7 @@ export class GranularAccess implements GranularAccessForBundle {
this._activeBundle = {
docSession, docActions, undo, userActions, isDirect,
applied: false, hasDeliberateRuleChange: false, hasAnyRuleChange: false,
maybeHasShareChanges: false,
options,
};
this._activeBundle.hasDeliberateRuleChange =
@ -497,6 +502,35 @@ export class GranularAccess implements GranularAccessForBundle {
return this._checkIncomingDocAction({docSession, action, actionIdx});
}
}));
const shares = this._docData.getMetaTable('_grist_Shares');
/**
* This is a good point at which to determine whether we may be
* making a change to special shares. If we may be, then currently
* we will reload any connected web clients accessing the document
* via a share.
*
* The role of the `maybeHasShareChanges` flag is to trigger
* reloads of web clients that are accessing the document via a
* share, if share configuration may have changed. It doesn't
* actually impact access control itself. The sketch of order of
* operations given in the docstring for the GranularAccess
* class is helpful for understanding this flow.
*
* At the time of writing, web client support for special shares
* is not an official feature - but it is super convenient for testing
* and will be important later.
*/
if (shares.getRowIds().length > 0 &&
docActions.some(
action => getTableId(action).startsWith('_grist'))) {
// TODO: could actually compare new rules with old rules and
// see if they've changed. Or could exclude some tables that
// could easily change without an impact on share rules,
// such as _grist_Attachments. Either improvement could
// greatly reduce unnecessary web client reloads for shares
// if that becomes an issue.
this._activeBundle.maybeHasShareChanges = true;
}
}
await this._canApplyCellActions(currentUser, userIsOwner);
@ -517,6 +551,8 @@ export class GranularAccess implements GranularAccessForBundle {
_grist_Tables_column: this._docData.getMetaTable('_grist_Tables_column').getTableDataAction(),
_grist_ACLResources: this._docData.getMetaTable('_grist_ACLResources').getTableDataAction(),
_grist_ACLRules: this._docData.getMetaTable('_grist_ACLRules').getTableDataAction(),
_grist_Shares: this._docData.getMetaTable('_grist_Shares').getTableDataAction(),
// WATCH OUT - Shares may need more tables, check.
});
for (const da of docActions) {
tmpDocData.receiveAction(da);
@ -534,6 +570,8 @@ export class GranularAccess implements GranularAccessForBundle {
throw new ApiError(err.message, 400);
}
}
// TODO: any changes needed to this logic for shares?
}
/**
@ -593,6 +631,11 @@ export class GranularAccess implements GranularAccessForBundle {
throw new ErrorWithCode('NEED_RELOAD', 'document needs reload, access rules changed');
}
const linkId = getDocSessionShare(docSession);
if (linkId && this._activeBundle?.maybeHasShareChanges) {
throw new ErrorWithCode('NEED_RELOAD', 'document needs reload, share may have changed');
}
// Optimize case where there are no rules to enforce.
if (!this._ruler.haveRules()) { return docActions; }
@ -1354,7 +1397,15 @@ export class GranularAccess implements GranularAccessForBundle {
await this.update();
return;
}
if (!this._ruler.haveRules()) { return; }
const shares = this._docData.getMetaTable('_grist_Shares');
if (shares.getRowIds().length > 0 &&
docActions.some(action => getTableId(action).startsWith('_grist'))) {
await this.update();
return;
}
if (!shares && !this._ruler.haveRules()) {
return;
}
// If there is a schema change, redo from scratch for now.
if (docActions.some(docAction => isSchemaAction(docAction))) {
await this.update();
@ -1799,6 +1850,20 @@ export class GranularAccess implements GranularAccessForBundle {
const attrs = this._getUserAttributes(docSession);
access = getDocSessionAccess(docSession);
const linkId = getDocSessionShare(docSession);
let shareRef: number = 0;
if (linkId) {
const rowIds = this._docData.getMetaTable('_grist_Shares').filterRowIds({
linkId,
});
if (rowIds.length > 1) {
throw new Error('Share identifier is not unique');
}
if (rowIds.length === 1) {
shareRef = rowIds[0];
}
}
if (docSession.forkingAsOwner) {
// For granular access purposes, we become an owner.
// It is a bit of a bluff, done on the understanding that this session will
@ -1823,6 +1888,7 @@ export class GranularAccess implements GranularAccessForBundle {
}
const user = new User();
user.Access = access;
user.ShareRef = shareRef || null;
const isAnonymous = fullUser?.id === this._homeDbManager?.getAnonymousUserId() ||
fullUser?.id === null;
user.UserID = (!isAnonymous && fullUser?.id) || null;
@ -2569,7 +2635,7 @@ export class Ruler {
* Update granular access from DocData.
*/
public async update(docData: DocData) {
await this.ruleCollection.update(docData, {log, compile: compileAclFormula, includeHelperCols: true});
await this.ruleCollection.update(docData, {log, compile: compileAclFormula, enrichRulesForImplementation: true});
// Also clear the per-docSession cache of rule evaluations.
this.clearCache();
@ -3039,6 +3105,8 @@ function getCensorMethod(tableId: string): (rec: RecordEditor) => void {
return rec => rec;
case '_grist_ACLRules':
return rec => rec;
case '_grist_Shares':
return rec => rec;
case '_grist_Cells':
return rec => rec.set('content', [GristObjCode.Censored]).set('userRef', '');
default:
@ -3174,6 +3242,7 @@ export class User implements UserInfo {
public Email: string | null = null;
public SessionID: string | null = null;
public UserRef: string | null = null;
public ShareRef: number | null = null;
[attribute: string]: any;
constructor(_info: Record<string, unknown> = {}) {

@ -338,6 +338,14 @@ export class Sharing {
const actionSummary = await this._activeDoc.handleTriggers(localActionBundle);
// Opportunistically use actionSummary to see if _grist_Shares was
// changed.
if (actionSummary.tableDeltas._grist_Shares) {
// This is a little risky, since it entangles us with home db
// availability. But we aren't doing a lot...?
await this._activeDoc.syncShares(makeExceptionalDocSession('system'));
}
await this._activeDoc.updateRowCount(sandboxActionBundle.rowCount, docSession);
// Broadcast the action to connected browsers.

@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',40,'','');
INSERT INTO _grist_DocInfo VALUES(1,'','','',41,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0);
@ -15,9 +15,9 @@ CREATE TABLE IF NOT EXISTS "_grist_External_table" (id INTEGER PRIMARY KEY, "tab
CREATE TABLE IF NOT EXISTS "_grist_TableViews" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "viewRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_TabItems" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "viewRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_TabBar" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "tabPos" NUMERIC DEFAULT 1e999);
CREATE TABLE IF NOT EXISTS "_grist_Pages" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "indentation" INTEGER DEFAULT 0, "pagePos" NUMERIC DEFAULT 1e999);
CREATE TABLE IF NOT EXISTS "_grist_Pages" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "indentation" INTEGER DEFAULT 0, "pagePos" NUMERIC DEFAULT 1e999, "shareRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Views" (id INTEGER PRIMARY KEY, "name" TEXT DEFAULT '', "type" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "description" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "description" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL, "shareOptions" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colRef" INTEGER DEFAULT 0, "width" INTEGER DEFAULT 0, "widgetOptions" TEXT DEFAULT '', "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
@ -35,6 +35,7 @@ INSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers','');
CREATE TABLE IF NOT EXISTS "_grist_ACLMemberships" (id INTEGER PRIMARY KEY, "parent" INTEGER DEFAULT 0, "child" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "pinned" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Cells" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "rowId" INTEGER DEFAULT 0, "root" BOOLEAN DEFAULT 0, "parentId" INTEGER DEFAULT 0, "type" INTEGER DEFAULT 0, "content" TEXT DEFAULT '', "userRef" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Shares" (id INTEGER PRIMARY KEY, "linkId" TEXT DEFAULT '', "options" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '');
CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent);
COMMIT;
`;
@ -43,7 +44,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',40,'','');
INSERT INTO _grist_DocInfo VALUES(1,'','','',41,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0);
INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2,3);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
@ -58,14 +59,14 @@ CREATE TABLE IF NOT EXISTS "_grist_TableViews" (id INTEGER PRIMARY KEY, "tableRe
CREATE TABLE IF NOT EXISTS "_grist_TabItems" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "viewRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_TabBar" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "tabPos" NUMERIC DEFAULT 1e999);
INSERT INTO _grist_TabBar VALUES(1,1,1);
CREATE TABLE IF NOT EXISTS "_grist_Pages" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "indentation" INTEGER DEFAULT 0, "pagePos" NUMERIC DEFAULT 1e999);
INSERT INTO _grist_Pages VALUES(1,1,0,1);
CREATE TABLE IF NOT EXISTS "_grist_Pages" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "indentation" INTEGER DEFAULT 0, "pagePos" NUMERIC DEFAULT 1e999, "shareRef" INTEGER DEFAULT 0);
INSERT INTO _grist_Pages VALUES(1,1,0,1,0);
CREATE TABLE IF NOT EXISTS "_grist_Views" (id INTEGER PRIMARY KEY, "name" TEXT DEFAULT '', "type" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '');
INSERT INTO _grist_Views VALUES(1,'Table1','raw_data','');
CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "description" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL);
INSERT INTO _grist_Views_section VALUES(1,1,1,'record','','',100,1,'','','','','','[]',0,0,0,'',NULL);
INSERT INTO _grist_Views_section VALUES(2,1,0,'record','','',100,1,'','','','','','',0,0,0,'',NULL);
INSERT INTO _grist_Views_section VALUES(3,1,0,'single','','',100,1,'','','','','','',0,0,0,'',NULL);
CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "description" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL, "shareOptions" TEXT DEFAULT '');
INSERT INTO _grist_Views_section VALUES(1,1,1,'record','','',100,1,'','','','','','[]',0,0,0,'',NULL,'');
INSERT INTO _grist_Views_section VALUES(2,1,0,'record','','',100,1,'','','','','','',0,0,0,'',NULL,'');
INSERT INTO _grist_Views_section VALUES(3,1,0,'single','','',100,1,'','','','','','',0,0,0,'',NULL,'');
CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colRef" INTEGER DEFAULT 0, "width" INTEGER DEFAULT 0, "widgetOptions" TEXT DEFAULT '', "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL);
INSERT INTO _grist_Views_section_field VALUES(1,1,1,2,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(2,1,2,3,0,'',0,0,'',NULL);
@ -92,6 +93,7 @@ INSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers','');
CREATE TABLE IF NOT EXISTS "_grist_ACLMemberships" (id INTEGER PRIMARY KEY, "parent" INTEGER DEFAULT 0, "child" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "pinned" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Cells" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "rowId" INTEGER DEFAULT 0, "root" BOOLEAN DEFAULT 0, "parentId" INTEGER DEFAULT 0, "type" INTEGER DEFAULT 0, "content" TEXT DEFAULT '', "userRef" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Shares" (id INTEGER PRIMARY KEY, "linkId" TEXT DEFAULT '', "options" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "Table1" (id INTEGER PRIMARY KEY, "manualSort" NUMERIC DEFAULT 1e999, "A" BLOB DEFAULT NULL, "B" BLOB DEFAULT NULL, "C" BLOB DEFAULT NULL);
CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent);
COMMIT;

@ -1289,3 +1289,21 @@ def migration40(tdset):
new_view_section_id += 1
return tdset.apply_doc_actions(doc_actions)
@migration(schema_version=41)
def migration41(tdset):
"""
Add a table for tracking special shares.
"""
doc_actions = [
actions.AddTable("_grist_Shares", [
schema.make_column("linkId", "Text"),
schema.make_column("options", "Text"),
schema.make_column("label", "Text"),
schema.make_column("description", "Text"),
]),
add_column('_grist_Pages', 'shareRef', 'Ref:_grist_Shares'),
add_column('_grist_Views_section', 'shareOptions', 'Text'),
]
return tdset.apply_doc_actions(doc_actions)

@ -15,7 +15,7 @@ import six
import actions
SCHEMA_VERSION = 40
SCHEMA_VERSION = 41
def make_column(col_id, col_type, formula='', isFormula=False):
return {
@ -163,6 +163,7 @@ def schema_create_actions():
make_column("viewRef", "Ref:_grist_Views"),
make_column("indentation", "Int"),
make_column("pagePos", "PositionNumber"),
make_column("shareRef", "Ref:_grist_Shares"),
]),
# All user views.
@ -199,6 +200,7 @@ def schema_create_actions():
make_column("embedId", "Text"),
# Points to formula columns that hold conditional formatting rules for this view section.
make_column("rules", "RefList:_grist_Tables_column"),
make_column("shareOptions", "Text"),
]),
# The fields of a view section.
actions.AddTable("_grist_Views_section_field", [
@ -350,6 +352,14 @@ def schema_create_actions():
make_column("content", "Text"),
make_column("userRef", "Text"),
]),
actions.AddTable('_grist_Shares', [
make_column('linkId', 'Text'), # Used to match records in home db without
# necessarily trusting the document much.
make_column('options', 'Text'),
make_column('label', 'Text'),
make_column('description', 'Text'),
]),
]

@ -18,7 +18,8 @@ class TestCompletion(test_engine.EngineTestCase):
'Email': 'foo@example.com',
'Access': 'owners',
'SessionID': 'u1',
'IsLoggedIn': True
'IsLoggedIn': True,
'ShareRef': None
}
def setUp(self):
@ -100,9 +101,10 @@ class TestCompletion(test_engine.EngineTestCase):
('user.Name', "'Foo'"),
('user.Origin', 'None'),
('user.SessionID', "'u1'"),
('user.ShareRef', 'None'),
('user.StudentInfo', 'Students[1]'),
('user.UserID', '1'),
('user.UserRef', "'1'"),
('user.UserRef', "'1'")
]
)
# Should follow user attribute references and autocomplete those types.
@ -133,7 +135,8 @@ class TestCompletion(test_engine.EngineTestCase):
'UserRef': '2',
'Access': 'owners',
'SessionID': 'u2',
'IsLoggedIn': True
'IsLoggedIn': True,
'ShareRef': None
}
self.assertEqual(
self.autocomplete("user.", "Schools", "lastModified", user2, row_id=2),
@ -145,6 +148,7 @@ class TestCompletion(test_engine.EngineTestCase):
('user.Name', "'Bar'"),
('user.Origin', 'None'),
('user.SessionID', "'u2'"),
('user.ShareRef', 'None'),
('user.UserID', '2'),
('user.UserRef', "'2'"),
]

@ -346,7 +346,8 @@ class TestRenames(test_engine.EngineTestCase):
'Email': 'foo@example.com',
'Access': 'owners',
'SessionID': 'u1',
'IsLoggedIn': True
'IsLoggedIn': True,
'ShareRef': None
}
# Renaming a table should not leave the old name available for auto-complete.

@ -572,7 +572,8 @@ class TestTriggerFormulas(test_engine.EngineTestCase):
'Email': 'foo.bar@getgrist.com',
'Access': 'owners',
'SessionID': 'u1',
'IsLoggedIn': True
'IsLoggedIn': True,
'ShareRef': None
}
user2 = {
'Name': 'Baz Qux',
@ -584,7 +585,8 @@ class TestTriggerFormulas(test_engine.EngineTestCase):
'Email': 'baz.qux@getgrist.com',
'Access': 'owners',
'SessionID': 'u2',
'IsLoggedIn': True
'IsLoggedIn': True,
'ShareRef': None
}
# Use formula to store last modified by data (user name and email). Check that it works as expected.
self.load_sample(self.sample)

@ -22,7 +22,8 @@ class TestUser(test_engine.EngineTestCase):
'Origin': 'https://getgrist.com',
'StudentInfo': ['Students', 1],
'SessionID': 'u1',
'IsLoggedIn': True
'IsLoggedIn': True,
'ShareRef': None
}
u = User(data, self.engine.tables)
self.assertEqual(u.Name, 'Foo Bar')
@ -51,7 +52,8 @@ class TestUser(test_engine.EngineTestCase):
'Origin': 'https://getgrist.com',
'StudentInfo': ['Students', 1],
'SessionID': 'u1',
'IsLoggedIn': True
'IsLoggedIn': True,
'ShareRef': None
}
u = User(data, self.engine.tables, is_sample=True)
self.assertEqual(u.StudentInfo.id, 0)

@ -20,6 +20,7 @@ the following fields:
- LinkKey: dictionary
- SessionID: string or None
- IsLoggedIn: boolean
- ShareRef: integer or None
Additional keys may be included, which may have a value that is
either None or of type tuple with the following shape:
@ -44,7 +45,7 @@ class User(object):
"""
def __init__(self, data, tables, is_sample=False):
for attr in ('Access', 'UserID', 'Email', 'Name', 'Origin', 'SessionID',
'IsLoggedIn', 'UserRef'):
'IsLoggedIn', 'UserRef', 'ShareRef'):
setattr(self, attr, data[attr])
self.LinkKey = LinkKey(data['LinkKey'])

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-shadow */
import {ActionSummary} from 'app/common/ActionSummary';
import {BulkColValues, UserAction} from 'app/common/DocActions';
import {SHARE_KEY_PREFIX} from 'app/common/gristUrls';
import {arrayRepeat} from 'app/common/gutil';
import {WebhookSummary} from 'app/common/Triggers';
import {DocAPI, DocState, UserAPIImpl} from 'app/common/UserAPI';
@ -39,6 +40,7 @@ import * as testUtils from 'test/server/testUtils';
import {waitForIt} from 'test/server/wait';
import defaultsDeep = require('lodash/defaultsDeep');
import pick = require('lodash/pick');
import { getDatabase } from 'test/testUtils';
const chimpy = configForUser('Chimpy');
const kiwi = configForUser('Kiwi');
@ -2814,6 +2816,44 @@ function testDocApi() {
assert.notEqual(resp.data.id, '');
});
it("handles /s/ variants for shares", async function () {
const wid = (await userApi.getOrgWorkspaces('current')).find((w) => w.name === 'Private')!.id;
const docId = await userApi.newDoc({name: 'BlankTest'}, wid);
// const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`;
const userActions = [
['AddRecord', '_grist_Shares', null, {
linkId: 'x',
options: '{"publish": true}'
}],
['UpdateRecord', '_grist_Views_section', 1,
{shareOptions: '{"publish": true, "form": true}'}],
['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}],
];
let resp: AxiosResponse;
resp = await axios.post(`${serverUrl}/api/docs/${docId}/apply`, userActions, chimpy);
assert.equal(resp.status, 200);
const db = await getDatabase();
const shares = await db.connection.query('select * from shares');
const {key} = shares[0];
resp = await axios.get(`${serverUrl}/api/docs/${docId}/tables/Table1/records`, chimpy);
assert.equal(resp.status, 200);
resp = await axios.get(`${serverUrl}/api/s/${key}/tables/Table1/records`, chimpy);
assert.equal(resp.status, 200);
resp = await axios.get(`${serverUrl}/api/docs/${key}/tables/Table1/records`, chimpy);
assert.equal(resp.status, 404);
resp = await axios.get(`${serverUrl}/api/docs/${SHARE_KEY_PREFIX}${key}/tables/Table1/records`, chimpy);
assert.equal(resp.status, 200);
resp = await axios.get(`${serverUrl}/api/s/${key}xxx/tables/Table1/records`, chimpy);
assert.equal(resp.status, 404);
});
it("document is protected during upload-and-import sequence", async function () {
if (!process.env.TEST_REDIS_URL) {
this.skip();

Loading…
Cancel
Save