1

I'd like to run a function that requires authorization when a user clicks/selects a cell in a given range. A simple onSelectionChange(e) trigger doesn't work because of the authorization problem, and installable triggers don't include onSelectionChange apparently.

Is there another way to do so please? Thanks!

Rubén
  • 34,714
  • 9
  • 70
  • 166

2 Answers2

2

For functions that don't involve ui/HtmlService, Simple triggers can be used to run some functions that require authorization(say privileged functions) by weakening security:

  • The flow: Trigger => onSelectionChange(no auth to fetch/execute privileged functions) => trigger custom functions(gain auth to fetch/no auth to execute privileged functions) => fetch/post => webapp(full auth to run privileged functions)

  • This solution is inspired by this, which directly uses installable triggers and plain access tokens to authorize custom functions. This is not recommended from a security perspective.

  • Although efforts have been taken to ensure security and privacy of user executing the following script, all attack vectors haven't been considered. The script might be vulnerable in a lot of areas, especially given the lack of crypto module support in the platform. Use at your own risk, where alternate solutions are infeasible.

  • In most cases, alternate solutions using menu/button/time triggers/installable triggers(which always runs under full auth) is preferred. A similar flow can be achieved using onEdit installable trigger + checkbox

To use the sample script, follow the following steps:

  • Set necessary scopes in the manifest file. For the sample script,

     "oauthScopes": ["https://www.googleapis.com/auth/script.send_mail"],
    
  • Publish a webapp for the explicit purpose of executing a function that requires authorization

    • Execute as "me"
    • Access: "Anyone"
  • Create a service account with no roles/permissions for the explicit purpose of authorizing webapps from custom functions

  • Create a service account key and copy it to the creds object in the sample script.

  • Share your project/spreadsheet with the service account (client_email)

  • Install Oauth2 library to create/sign jwt tokens for service account

  • Create a hiddenSheet for setting a custom function, which will be set to set to A1 of this sheet onSelectionChange

  • The following script sends email, when someone touches anything in your spreadsheet.

Sample script:

/**
 * Gets Oauth2 service based on service account with drive scope
 * Drive scope needed to access webapp with access:anyone
 * This does not grant access to the user's drive but the service
 *     account's drive, which will only contain the file shared with it
 */
function getService_() {
  const creds = {
    private_key: '[PRIVATE_KEY]',
    client_email: '[CLIENT_EMAIL]',
  };
  const PRIVATE_KEY = creds['private_key'];
  const CLIENT_EMAIL = creds['client_email'];
  return OAuth2.createService('GoogleDrive:')
    .setTokenUrl('https://oauth2.googleapis.com/token')
    .setPrivateKey(PRIVATE_KEY)
    .setIssuer(CLIENT_EMAIL)
    .setPropertyStore(PropertiesService.getUserProperties())
    .setScope('https://www.googleapis.com/auth/drive');
}
/**
 * @returns {string} base64 encoded string of SHA_512 digest of random uuidstring
 */
const getRandHashKey_ = () =>
  Utilities.base64EncodeWebSafe(
    Utilities.computeDigest(
      Utilities.DigestAlgorithm.SHA_512,
      Utilities.getUuid() //type 4 advertised crypto secure
    )
  );

/**
 * @param {GoogleAppsScript.Events.SheetsOnSelectionChange} e
 */
const onSelectionChange = e => {
  const sCache = CacheService.getScriptCache();
  e.rangestr = e.range.getSheet().getName() + '!' + e.range.getA1Notation();
  const hashRandom = getRandHashKey_();
  sCache.put(hashRandom, JSON.stringify(e), 20);//expires in 20 seconds
  e.source
    .getSheetByName('hiddenSheet')
    .getRange('A1')
    .setValue(`=CALLWEBAPP("${hashRandom}")`);
};
/**
 * Calls published webapp(Access:Anyone) with service account token
 * @customfunction
 * @returns void
 */
const callwebapp = randomHash => {
  const webAppScriptId = '[SCRIPT_ID]';
  UrlFetchApp.fetch(
    `https://script.google.com/macros/s/${webAppScriptId}/exec`,
    {
      method: 'post',
      payload: { e: randomHash },
      headers: { Authorization: `Bearer ${getService_().getAccessToken()}` },
    }
  );
};

/**
 * @param {GoogleAppsScript.Events.AppsScriptHttpRequestEvent} e
 */
const doPost = e => {
  const hashRandom = e.parameter.e;
  const sCache = CacheService.getScriptCache();
  const encodedSelectionEvent = sCache.get(hashRandom);
  if (encodedSelectionEvent) {
    const selectionEvent = JSON.parse(encodedSelectionEvent);
    MailApp.sendEmail(
      '[EMAIL_TO_SEND_NOTIFICATION_TO]',
      'Someone touched your spreadsheet',
      `Wanna take a look? ${selectionEvent.rangestr} was touched without your permission`
    );
  }
};
TheMaster
  • 45,448
  • 6
  • 62
  • 85
0

So I ended up adding a column with the value "Clear to run function" on each row, with a simple onEdit(e) that triggers my function if one value in this column in different from "Clear to run function".

From a UX perspective, it means clearing a cell to run the function - not ideal, but it works.