mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) implement authorization via query parameter
Summary: This adds any parameters in a document url whose key ends in '_' into a `user.Link` object available in access control formulas and in setting up characteristic tables. This allows, for example, sending links to a document that contain a hard-to-guess token, and having that link grant access to a controlled part of the document (invoices for a specific customer for example). A `user.Origin` field is also added, set during rest api calls, but is only tested manually at this point. It could be elaborated for embedding use-cases. Test Plan: added test Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2680
This commit is contained in:
		
							parent
							
								
									131fbbdb92
								
							
						
					
					
						commit
						8f023a6446
					
				@ -267,8 +267,9 @@ 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): Promise<OpenLocalDocResult> {
 | 
			
		||||
    return this._makeRequest(null, docName, 'openDoc', docName, mode);
 | 
			
		||||
  public async openDoc(docName: string, mode?: string,
 | 
			
		||||
                       linkParameters?: Record<string, string>): Promise<OpenLocalDocResult> {
 | 
			
		||||
    return this._makeRequest(null, docName, 'openDoc', docName, mode, linkParameters);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
 | 
			
		||||
@ -189,6 +189,7 @@ export class DocComm extends Disposable implements ActiveDocAPI {
 | 
			
		||||
  private async _doForkDoc(): Promise<void> {
 | 
			
		||||
    reportError(new UserError('Preparing your copy...', {key: 'forking'}));
 | 
			
		||||
    const {urlId, docId} = await this.fork();
 | 
			
		||||
    // TODO: may want to preserve linkParameters in call to openDoc.
 | 
			
		||||
    const openResponse = await this._comm.openDoc(docId);
 | 
			
		||||
    // Close the old doc and release the old connection. Note that the closeDoc call is expected
 | 
			
		||||
    // to fail, since we close the websocket immediately after it. So let it fail silently.
 | 
			
		||||
 | 
			
		||||
@ -116,6 +116,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
 | 
			
		||||
    this.autoDispose(subscribe(urlState().state, (use, state) => {
 | 
			
		||||
      const urlId = state.doc;
 | 
			
		||||
      const urlOpenMode = state.mode || 'default';
 | 
			
		||||
      const linkParameters = state.params?.linkParameters;
 | 
			
		||||
      const docKey = this._getDocKey(state);
 | 
			
		||||
      if (docKey !== this._openerDocKey) {
 | 
			
		||||
        this._openerDocKey = docKey;
 | 
			
		||||
@ -126,7 +127,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
 | 
			
		||||
          this._openerHolder.clear();
 | 
			
		||||
        } else {
 | 
			
		||||
          FlowRunner.create(this._openerHolder, (flow: AsyncFlow) => this._openDoc(flow, urlId, urlOpenMode,
 | 
			
		||||
                                                                                  state.params?.compare))
 | 
			
		||||
                                                                                   state.params?.compare, linkParameters))
 | 
			
		||||
          .resultPromise.catch(err => this._onOpenError(err));
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
@ -207,7 +208,8 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async _openDoc(flow: AsyncFlow, urlId: string, urlOpenMode: OpenDocMode,
 | 
			
		||||
                         comparisonUrlId: string | undefined): Promise<void> {
 | 
			
		||||
                         comparisonUrlId: string | undefined,
 | 
			
		||||
                         linkParameters: Record<string, string> | undefined): Promise<void> {
 | 
			
		||||
    console.log(`DocPageModel _openDoc starting for ${urlId} (mode ${urlOpenMode})` +
 | 
			
		||||
                (comparisonUrlId ? ` (compare ${comparisonUrlId})` : ''));
 | 
			
		||||
    const gristDocModulePromise = loadGristDoc();
 | 
			
		||||
@ -229,7 +231,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
 | 
			
		||||
    comm.useDocConnection(doc.id);
 | 
			
		||||
    flow.onDispose(() => comm.releaseDocConnection(doc.id));
 | 
			
		||||
 | 
			
		||||
    const openDocResponse = await comm.openDoc(doc.id, doc.openMode);
 | 
			
		||||
    const openDocResponse = await comm.openDoc(doc.id, doc.openMode, linkParameters);
 | 
			
		||||
    const gdModule = await gristDocModulePromise;
 | 
			
		||||
    const docComm = gdModule.DocComm.create(flow, comm, openDocResponse, doc.id, this.appModel.notifier);
 | 
			
		||||
    flow.checkIfCancelled();
 | 
			
		||||
 | 
			
		||||
@ -80,5 +80,6 @@ export interface DocListAPI {
 | 
			
		||||
  /**
 | 
			
		||||
   * Opens a document, loads it, subscribes to its userAction events, and returns its metadata.
 | 
			
		||||
   */
 | 
			
		||||
  openDoc(userDocName: string, openMode?: OpenDocMode): Promise<OpenLocalDocResult>;
 | 
			
		||||
  openDoc(userDocName: string, openMode?: OpenDocMode,
 | 
			
		||||
          linkParameters?: Record<string, string>): Promise<OpenLocalDocResult>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -26,7 +26,7 @@ export interface InfoView {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Represents user info, which may include properties which are themselves RowRecords.
 | 
			
		||||
export type UserInfo = Record<string, CellValue|InfoView>;
 | 
			
		||||
export type UserInfo = Record<string, CellValue|InfoView|Record<string, string>>;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Input into the AclMatchFunc. Compiled formulas evaluate AclMatchInput to produce a boolean.
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,6 @@ import {OpenDocMode} from 'app/common/DocListAPI';
 | 
			
		||||
import {encodeQueryParams, isAffirmative} from 'app/common/gutil';
 | 
			
		||||
import {localhostRegex} from 'app/common/LoginState';
 | 
			
		||||
import {Document} from 'app/common/UserAPI';
 | 
			
		||||
import identity = require('lodash/identity');
 | 
			
		||||
import pickBy = require('lodash/pickBy');
 | 
			
		||||
import {StringUnion} from './StringUnion';
 | 
			
		||||
 | 
			
		||||
@ -67,6 +66,8 @@ export interface IGristUrlState {
 | 
			
		||||
    style?: InterfaceStyle;
 | 
			
		||||
    compare?: string;
 | 
			
		||||
    aclUI?: boolean;
 | 
			
		||||
    linkParameters?: Record<string, string>;  // Parameters to pass as 'user.Link' in granular ACLs.
 | 
			
		||||
                                              // Encoded in URL as query params with extra '_' suffix.
 | 
			
		||||
  };
 | 
			
		||||
  hash?: HashLink;   // if present, this specifies an individual row within a section of a page.
 | 
			
		||||
}
 | 
			
		||||
@ -173,10 +174,13 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
 | 
			
		||||
    parts.push(`welcome/${state.welcome}`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const queryParams = pickBy(state.params, identity) as {[key: string]: string};
 | 
			
		||||
  const queryParams = pickBy(state.params, (v, k) => k !== 'linkParameters') as {[key: string]: string};
 | 
			
		||||
  if (state.newui !== undefined) {
 | 
			
		||||
    queryParams.newui = state.newui ? '1' : '0';
 | 
			
		||||
  }
 | 
			
		||||
  for (const [k, v] of Object.entries(state.params?.linkParameters || {})) {
 | 
			
		||||
    queryParams[`${k}_`] = v;
 | 
			
		||||
  }
 | 
			
		||||
  const hashParts: string[] = [];
 | 
			
		||||
  if (state.hash && state.hash.rowId) {
 | 
			
		||||
    const hash = state.hash;
 | 
			
		||||
@ -264,6 +268,12 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
 | 
			
		||||
  if (sp.has('aclUI')) {
 | 
			
		||||
    state.params!.aclUI = isAffirmative(sp.get('aclUI'));
 | 
			
		||||
  }
 | 
			
		||||
  for (const [k, v] of sp.entries()) {
 | 
			
		||||
    if (k.endsWith('_')) {
 | 
			
		||||
      if (!state.params!.linkParameters) { state.params!.linkParameters = {}; }
 | 
			
		||||
      state.params!.linkParameters[k.slice(0, k.length - 1)] = v;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  if (location.hash) {
 | 
			
		||||
    const hash = location.hash;
 | 
			
		||||
    const hashParts = hash.split('.');
 | 
			
		||||
 | 
			
		||||
@ -363,6 +363,9 @@ export interface Authorizer {
 | 
			
		||||
  // get the id of the document.
 | 
			
		||||
  getDocId(): string;
 | 
			
		||||
 | 
			
		||||
  // get any link parameters in place when accessing the resource.
 | 
			
		||||
  getLinkParameters(): Record<string, string>;
 | 
			
		||||
 | 
			
		||||
  // Fetch the doc metadata from HomeDBManager.
 | 
			
		||||
  getDoc(): Promise<Document>;
 | 
			
		||||
 | 
			
		||||
@ -385,6 +388,7 @@ export class DocAuthorizer implements Authorizer {
 | 
			
		||||
    private _dbManager: HomeDBManager,
 | 
			
		||||
    private _key: DocAuthKey,
 | 
			
		||||
    public readonly openMode: OpenDocMode,
 | 
			
		||||
    public readonly linkParameters: Record<string, string>,
 | 
			
		||||
    private _docAuth?: DocAuthResult,
 | 
			
		||||
    private _profile?: UserProfile
 | 
			
		||||
  ) {
 | 
			
		||||
@ -403,6 +407,10 @@ export class DocAuthorizer implements Authorizer {
 | 
			
		||||
    return this._key.urlId;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public getLinkParameters(): Record<string, string> {
 | 
			
		||||
    return this.linkParameters;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getDoc(): Promise<Document> {
 | 
			
		||||
    return this._dbManager.getDoc(this._key);
 | 
			
		||||
  }
 | 
			
		||||
@ -424,6 +432,7 @@ export class DummyAuthorizer implements Authorizer {
 | 
			
		||||
  public getUserId() { return null; }
 | 
			
		||||
  public getUser() { return null; }
 | 
			
		||||
  public getDocId() { return this.docId; }
 | 
			
		||||
  public getLinkParameters() { return {}; }
 | 
			
		||||
  public async getDoc(): Promise<Document> { throw new Error("Not supported in standalone"); }
 | 
			
		||||
  public async assertAccess() { /* noop */ }
 | 
			
		||||
  public getCachedAuth(): DocAuthResult {
 | 
			
		||||
@ -481,11 +490,14 @@ export function getTransitiveHeaders(req: Request): {[key: string]: string} {
 | 
			
		||||
  const PermitHeader = req.get('Permit');
 | 
			
		||||
  const Organization = (req as RequestWithOrg).org;
 | 
			
		||||
  const XRequestedWith = req.get('X-Requested-With');
 | 
			
		||||
  const Origin = req.get('Origin');  // Pass along the original Origin since it may
 | 
			
		||||
                                     // play a role in granular access control.
 | 
			
		||||
  return {
 | 
			
		||||
    ...(Authorization ? { Authorization } : undefined),
 | 
			
		||||
    ...(Cookie ? { Cookie } : undefined),
 | 
			
		||||
    ...(Organization ? { Organization } : undefined),
 | 
			
		||||
    ...(PermitHeader ? { Permit: PermitHeader } : undefined),
 | 
			
		||||
    ...(XRequestedWith ? { 'X-Requested-With': XRequestedWith } : undefined),
 | 
			
		||||
    ...(Origin ? { Origin } : undefined),
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -244,7 +244,8 @@ export class DocManager extends EventEmitter {
 | 
			
		||||
   *      `doc` - the object with metadata tables.
 | 
			
		||||
   */
 | 
			
		||||
  public async openDoc(client: Client, docId: string,
 | 
			
		||||
                       mode: OpenDocMode = 'default'): Promise<OpenLocalDocResult> {
 | 
			
		||||
                       mode: OpenDocMode = 'default',
 | 
			
		||||
                       linkParameters: Record<string, string> = {}): Promise<OpenLocalDocResult> {
 | 
			
		||||
    let auth: Authorizer;
 | 
			
		||||
    const dbManager = this._homeDbManager;
 | 
			
		||||
    if (!isSingleUserMode()) {
 | 
			
		||||
@ -266,7 +267,7 @@ export class DocManager extends EventEmitter {
 | 
			
		||||
        // than a docId.
 | 
			
		||||
        throw new Error(`openDoc expected docId ${docAuth.docId} not urlId ${docId}`);
 | 
			
		||||
      }
 | 
			
		||||
      auth = new DocAuthorizer(dbManager, key, mode, docAuth, client.getProfile() || undefined);
 | 
			
		||||
      auth = new DocAuthorizer(dbManager, key, mode, linkParameters, docAuth, client.getProfile() || undefined);
 | 
			
		||||
    } else {
 | 
			
		||||
      log.debug(`DocManager.openDoc not using authorization for ${docId} because GRIST_SINGLE_USER`);
 | 
			
		||||
      auth = new DummyAuthorizer('owners', docId);
 | 
			
		||||
 | 
			
		||||
@ -694,7 +694,7 @@ export class GranularAccess {
 | 
			
		||||
    if (value && typeof value === 'object' && !Array.isArray(value)) {
 | 
			
		||||
      value = value.get('id');
 | 
			
		||||
    }
 | 
			
		||||
    return JSON.stringify(value).toLowerCase();
 | 
			
		||||
    return JSON.stringify(value)?.toLowerCase() || '';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
@ -755,6 +755,12 @@ export class GranularAccess {
 | 
			
		||||
    user.UserID = fullUser?.id || null;
 | 
			
		||||
    user.Email = fullUser?.email || null;
 | 
			
		||||
    user.Name = fullUser?.name || null;
 | 
			
		||||
    // If viewed from a websocket, collect any link parameters included.
 | 
			
		||||
    // TODO: could also get this from rest api access, just via a different route.
 | 
			
		||||
    user.Link = docSession.authorizer?.getLinkParameters() || {};
 | 
			
		||||
    // Include origin info if accessed via the rest api.
 | 
			
		||||
    // TODO: could also get this for websocket access, just via a different route.
 | 
			
		||||
    user.Origin = docSession.req?.get('origin') || null;
 | 
			
		||||
 | 
			
		||||
    for (const clause of this._ruleCollection.getUserAttributeRules().values()) {
 | 
			
		||||
      if (clause.name in user) {
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user