(core) add an access token mechanism to help with attachments in custom widgets

Summary:
With this, a custom widget can render an attachment by doing:
```
const tokenInfo = await grist.docApi.getAccessToken({readOnly: true});
const img = document.getElementById('the_image');
const id = record.C[0];  // get an id of an attachment
const src = `${tokenInfo.baseUrl}/attachments/${id}/download?auth=${tokenInfo.token}`;
img.setAttribute('src', src)
```

The access token expires after a few mins, so if a user right-clicks on an image
to save it, they may get access denied unless they refresh the page. A little awkward,
but s3 pre-authorized links behave similarly and it generally isn't a deal-breaker.

Test Plan: added tests

Reviewers: dsagal

Reviewed By: dsagal

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D3488
This commit is contained in:
Paul Fitzpatrick
2022-07-19 11:39:49 -04:00
parent 5c0a250309
commit dd8d2e18f5
22 changed files with 551 additions and 34 deletions

View File

@@ -59,6 +59,7 @@ import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccess
import {parseUrlId} from 'app/common/gristUrls';
import {byteString, countIf, retryOnce, safeJsonParse} from 'app/common/gutil';
import {InactivityTimer} from 'app/common/InactivityTimer';
import * as roles from 'app/common/roles';
import {schema, SCHEMA_VERSION} from 'app/common/schema';
import {MetaRowRecord, SingleCell} from 'app/common/TableData';
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
@@ -67,7 +68,7 @@ import {convertFromColumn} from 'app/common/ValueConverter';
import {guessColInfoWithDocData} from 'app/common/ValueGuesser';
import {parseUserAction} from 'app/common/ValueParser';
import {ParseOptions} from 'app/plugin/FileParserAPI';
import {GristDocAPI} from 'app/plugin/GristAPI';
import {AccessTokenOptions, AccessTokenResult, GristDocAPI} from 'app/plugin/GristAPI';
import {compileAclFormula} from 'app/server/lib/ACLFormula';
import {Authorizer} from 'app/server/lib/Authorizer';
import {checksumFile} from 'app/server/lib/checksumFile';
@@ -102,6 +103,7 @@ import {DocClients} from './DocClients';
import {DocPluginManager} from './DocPluginManager';
import {
DocSession,
getDocSessionAccess,
getDocSessionUser,
getDocSessionUserId,
makeExceptionalDocSession,
@@ -1350,6 +1352,33 @@ export class ActiveDoc extends EventEmitter {
return forkIds;
}
public async getAccessToken(docSession: OptDocSession, options: AccessTokenOptions): Promise<AccessTokenResult> {
const tokens = this._docManager.gristServer.getAccessTokens();
const userId = getDocSessionUserId(docSession);
const docId = this.docName;
const access = getDocSessionAccess(docSession);
// If we happen to be using a "readOnly" connection, max out at "readOnly"
// even if user could do more.
if (roles.getStrongestRole('viewers', access) === 'viewers') {
options.readOnly = true;
}
// Return a token that can be used to authorize as the given user.
if (!userId) { throw new Error('creating access token requires a user'); }
const token = await tokens.sign({
readOnly: options.readOnly,
userId, // definitely do not want userId overridable by options.
docId, // likewise for docId.
});
const ttlMsecs = tokens.getNominalTTLInMsec();
const baseUrl = this._options?.docApiUrl;
if (!baseUrl) { throw new Error('cannot create token without URLs'); }
return {
token,
baseUrl,
ttlMsecs,
};
}
/**
* Check if an ACL formula is valid. If not, will throw an error with an explanation.
*/