🧾事務自動化ラボ

スプレッドシートで入力セルだけ編集可にする(GAS・保護範囲の自動設定)

共有スプレッドシートの数式・見出し・マスタを保護し、入力欄だけを編集できるようにするGASです。protect()だけでは保護にならない理由、グループ・ドメイン権限、保護の重複まで点検します。

共有スプレッドシートを運用していると、「入力はしてほしいが、数式と見出しは触ってほしくない」という場面が出ます。色を付けて「黄色いセルだけ入力」と書いても、貼り付けや行削除で数式は消えます。

この記事では、シート全体を保護し、入力欄だけを保護の例外にするGoogle Apps Script(GAS)を作ります。数式セルを1個ずつ探して保護する方式ではありません。後から数式列を追加しても、入力欄として明示しない限り保護される設計です。

作るもの

例として「入力台帳」シートを、次の権限にします。

範囲 内容 一般の編集者
A2:D1000 日付・担当者・案件・数量 編集可
F2:F1000 備考 編集可
1行目 見出し 編集不可
E列 計算式 編集不可
上記以外 マスタ・補助式 編集不可

ファイル自体の共有設定は変えません。Googleスプレッドシートの「編集者」である人のうち、入力欄は全員が編集でき、保護部分は所有者と設定した管理者だけが編集できる状態にします。

コード

スプレッドシートで 拡張機能 > Apps Script を開き、エディタの中身を消して次のコードを貼ります。

/**
 * Googleスプレッドシートで「入力欄だけ編集可」にする保護設定。
 * シート全体を保護し、入力欄を例外として開ける方式です。
 */
const PROTECTION_CFG = Object.freeze({
  sheetName: '入力台帳',
  description: '事務自動化ラボ: 入力欄以外を保護',
  inputRanges: ['A2:D1000', 'F2:F1000'],
  // 数式や見出しも編集できる管理者。ファイル自体の共有権限は別途必要です。
  adminEditors: [],
});

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('保護設定')
    .addItem('入力欄だけ編集可にする', 'applyInputOnlyProtection')
    .addItem('現在の設定を点検する', 'auditInputOnlyProtection')
    .addToUi();
}

function applyInputOnlyProtection() {
  const ss = SpreadsheetApp.getActive();
  const sheet = ss.getSheetByName(PROTECTION_CFG.sheetName);
  if (!sheet) throw new Error(`シート「${PROTECTION_CFG.sheetName}」が見つかりません`);

  const inputRanges = normalizeInputRanges_(sheet, PROTECTION_CFG.inputRanges);
  const access = getManagedAccess_(ss);
  const state = getProtectionState_(sheet, inputRanges);

  if (state.managed.length > 1) {
    throw new Error(
      `同じ説明の保護が${state.managed.length}件あります。` +
      'データ > シートと範囲を保護 で重複を確認してください。',
    );
  }
  if (state.unmanagedSheets.length) {
    throw new Error(
      '別のシート保護がすでにあります。上書きせず、' +
      'データ > シートと範囲を保護 で内容を確認してください。',
    );
  }
  if (state.overlappingRanges.length) {
    throw new Error(`入力欄と重なる範囲保護があります: ${state.overlappingRanges.join(', ')}`);
  }

  const protection = state.managed[0] || sheet.protect();
  if (!protection.canEdit()) throw new Error('この保護設定を変更する権限がありません');

  protection
    .setDescription(PROTECTION_CFG.description)
    .setWarningOnly(false)
    .setUnprotectedRanges(inputRanges);

  // グループ経由で編集している利用者を先に外すと例外になるため、
  // 実行者を明示的な編集者へ加えてから、不要な権限を外します。
  protection.addEditor(access.me);
  protection.addEditors(access.allowedEmails);

  const removeEmails = protection
    .getEditors()
    .map((user) => normalizeEmail_(user.getEmail()))
    .filter((email) => email && !access.allowedSet.has(email));
  if (removeEmails.length) protection.removeEditors(removeEmails);

  getTargetAudienceIds_(protection)
    .forEach((id) => protection.removeTargetAudience(id));

  // 個別編集者を外しても、ドメイン全体の編集が有効なら保護になりません。
  if (protection.canDomainEdit()) protection.setDomainEdit(false);

  const result = inspectProtection_(protection);
  SpreadsheetApp.getUi().alert(
    '保護設定を更新しました\n' +
    `入力可能: ${result.unprotected.join(', ')}\n` +
    `保護部分の編集者: ${result.editors.join(', ') || '(取得できません)'}`,
  );
  return result;
}

function auditInputOnlyProtection() {
  const ss = SpreadsheetApp.getActive();
  const sheet = ss.getSheetByName(PROTECTION_CFG.sheetName);
  if (!sheet) throw new Error(`シート「${PROTECTION_CFG.sheetName}」が見つかりません`);

  const inputRanges = normalizeInputRanges_(sheet, PROTECTION_CFG.inputRanges);
  const expectedRanges = inputRanges.map((range) => range.getA1Notation()).sort();
  const access = getManagedAccess_(ss, false);
  const state = getProtectionState_(sheet, inputRanges);
  const problems = [];

  if (state.managed.length !== 1) {
    problems.push(`対象のシート保護が${state.managed.length}件です(正常は1件)`);
  }
  if (state.unmanagedSheets.length) {
    problems.push(`別のシート保護が${state.unmanagedSheets.length}件あります`);
  }
  if (state.overlappingRanges.length) {
    problems.push(`入力欄と重なる範囲保護があります: ${state.overlappingRanges.join(', ')}`);
  }

  if (state.managed.length === 1) {
    const protection = state.managed[0];
    if (!protection.canEdit()) {
      problems.push('この利用者では保護の編集者・対象オーディエンスを点検できません');
    } else {
      const result = inspectProtection_(protection);
      if (result.warningOnly) problems.push('警告のみで、編集を止めていません');
      if (result.domainEdit) problems.push('ドメイン全体の編集が許可されています');
      if (result.targetAudiences.length) {
        problems.push(`対象オーディエンスが残っています: ${result.targetAudiences.join(', ')}`);
      }
      if (JSON.stringify(result.unprotected) !== JSON.stringify(expectedRanges)) {
        problems.push(`入力可能範囲が設定と違います: ${result.unprotected.join(', ') || 'なし'}`);
      }
      if (JSON.stringify(result.editors) !== JSON.stringify(access.allowedEmails)) {
        problems.push(`保護部分の編集者が設定と違います: ${result.editors.join(', ') || 'なし'}`);
      }
    }
  }

  const message = problems.length
    ? `要確認\n- ${problems.join('\n- ')}`
    : `問題なし\n入力可能: ${expectedRanges.join(', ')}`;
  SpreadsheetApp.getUi().alert(message);
  return { ok: problems.length === 0, problems };
}

function getManagedAccess_(ss, requireRunner = true) {
  const me = Session.getEffectiveUser();
  const meEmail = normalizeEmail_(me && me.getEmail());
  const owner = typeof ss.getOwner === 'function' ? ss.getOwner() : null;
  const ownerEmail = normalizeEmail_(owner && owner.getEmail());
  const admins = (PROTECTION_CFG.adminEditors || []).map(normalizeEmail_).filter(Boolean);
  const allowedEmails = [...new Set([ownerEmail, ...admins].filter(Boolean))].sort();
  const allowedSet = new Set(allowedEmails);

  if (!allowedEmails.length) {
    throw new Error(
      '所有者を取得できません。共有ドライブでは adminEditors に管理者を指定してください。',
    );
  }
  if (requireRunner && (!meEmail || !allowedSet.has(meEmail))) {
    throw new Error('所有者または adminEditors に指定した管理者が実行してください');
  }
  return { me, meEmail, allowedEmails, allowedSet };
}

function getProtectionState_(sheet, inputRanges) {
  const sheetProtections = sheet.getProtections(SpreadsheetApp.ProtectionType.SHEET);
  const managed = sheetProtections.filter(
    (protection) => protection.getDescription() === PROTECTION_CFG.description,
  );
  const unmanagedSheets = sheetProtections.filter(
    (protection) => protection.getDescription() !== PROTECTION_CFG.description,
  );
  const overlappingRanges = sheet
    .getProtections(SpreadsheetApp.ProtectionType.RANGE)
    .map((protection) => protection.getRange())
    .filter((range) => inputRanges.some((input) => rangesOverlap_(range, input)))
    .map((range) => range.getA1Notation())
    .sort();
  return { managed, unmanagedSheets, overlappingRanges };
}

function rangesOverlap_(a, b) {
  const aLastRow = a.getRow() + a.getNumRows() - 1;
  const aLastCol = a.getColumn() + a.getNumColumns() - 1;
  const bLastRow = b.getRow() + b.getNumRows() - 1;
  const bLastCol = b.getColumn() + b.getNumColumns() - 1;
  return !(
    aLastRow < b.getRow() || bLastRow < a.getRow() ||
    aLastCol < b.getColumn() || bLastCol < a.getColumn()
  );
}

function normalizeInputRanges_(sheet, a1Notations) {
  const unique = [...new Set(
    (a1Notations || []).map((value) => String(value).trim()).filter(Boolean),
  )];
  if (!unique.length) throw new Error('inputRanges が空です。入力欄を1つ以上指定してください');
  return unique.map((a1) => sheet.getRange(a1));
}

function normalizeEmail_(email) {
  return String(email || '').trim().toLowerCase();
}

function getTargetAudienceIds_(protection) {
  return (protection.getTargetAudiences() || [])
    .map((value) => {
      if (typeof value === 'string') return value;
      if (value && typeof value.getAudienceId === 'function') return value.getAudienceId();
      if (value && typeof value.getId === 'function') return value.getId();
      return String(value || '');
    })
    .filter(Boolean)
    .sort();
}

function inspectProtection_(protection) {
  return {
    warningOnly: protection.isWarningOnly(),
    domainEdit: protection.canDomainEdit(),
    unprotected: protection.getUnprotectedRanges().map((range) => range.getA1Notation()).sort(),
    editors: protection
      .getEditors()
      .map((user) => normalizeEmail_(user.getEmail()))
      .filter(Boolean)
      .sort(),
    targetAudiences: getTargetAudienceIds_(protection),
  };
}

コード先頭の3項目だけ、自分のシートに合わせて変更します。

設定 入れるもの
sheetName 保護するシートのタブ名
inputRanges 一般の編集者が入力してよい範囲。A1形式で複数指定可
adminEditors 保護部分も編集する管理者のメールアドレス。所有者が実行するなら空配列で可

保存したらスプレッドシートを再読み込みします。所有者、または adminEditors に指定した管理者が、上部の 保護設定 > 入力欄だけ編集可にする を押してください。初回はスプレッドシートを操作する権限の承認が出ます。共有ドライブでは所有者を取得できないため、adminEditors の指定が必須です。

protect() を呼ぶだけでは保護にならない

最初につまずいたのは、次の2行を書いて「これで保護された」と思ったことでした。

const protection = sheet.protect();
protection.setDescription('数式を保護');

Google Apps Scriptの公式リファレンスには、protect() で保護オブジェクトを作っても、編集者の追加・削除やドメイン編集の変更を明示的に行うまでは、権限がスプレッドシート本体と同じままで実質的に保護されないとあります(Range.protect() / Sheet.protect())。

そのため、コードでは次の順で設定しています。

  1. シート全体を保護する
  2. setUnprotectedRanges() で入力欄を例外にする
  3. 所有者または指定管理者が実行していることを確認する
  4. 実行者を保護部分の編集者へ明示的に追加する
  5. 所有者・指定管理者以外の編集者と対象オーディエンスを外す
  6. ドメイン全体の編集が有効なら無効にする

「セルに色を付けた」と「編集を止めた」は別です。動作確認は、可能なら別の編集者アカウントで入力欄と数式セルを1回ずつ編集して確かめてください。

数式セルだけを探して保護しない理由

数式が入っているセルだけを getFormulas() で探して保護する方法もあります。ただし、その方法だと次の場所が残ります。

  • 見出し
  • 入力規則
  • まだ数式を入れていない将来行
  • マスタの固定値
  • 補助列の空欄

数式だけを守ると、「数式は消えていないが、見出しや選択肢が変わって集計が壊れた」という状態になります。そこでこのコードは逆に、入力してよい範囲だけを列挙します。

公式の Protection.setUnprotectedRanges() は、保護したシートの中に編集可能な例外範囲を作るためのメソッドです(Protectionクラス)。

つまずいた箇所と、その原因

1. 「警告を表示」と「編集を制限」は違う

保護設定には、編集時に警告だけ出すモードがあります。警告を閉じれば編集できるため、数式の破壊を止める用途には足りません。

コードでは setWarningOnly(false) を明示し、点検メニューでも isWarningOnly() を確認します。Googleのヘルプでも、保護設定は「警告を表示」と「編集できるユーザーを制限」が別の選択肢です(シートと範囲を保護する)。

2. グループ経由の編集者を先に外すと例外になる

Google Workspaceのグループ経由で編集権限を持つ人が実行した場合、グループを先に保護編集者から外すと、実行者自身が保護設定を変更できなくなり、途中で例外になることがあります。

Google公式のサンプルも、Session.getEffectiveUser() で実行者を先に追加してから既存編集者を外しています。このコードも同じ順番です。

3. 個別編集者を外しても、ドメイン全体の編集が残る

会社や学校のGoogle Workspaceでは、「ドメイン内の全員が編集可」が有効な場合があります。個別の編集者一覧だけを掃除しても、ドメイン権限が残れば保護部分を編集できます。

そのため、canDomainEdit() が true のときだけ setDomainEdit(false) を呼びます。個人の gmail.com 所有シートで無条件に setDomainEdit(false) を呼ぶと例外になるため、先に判定します。

4. addEditor() はファイルの共有権限を付けない

adminEditors にメールアドレスを入れても、その人がスプレッドシート自体を開けるようになるわけではありません。公式リファレンスにも、保護範囲の addEditor() はファイル本体の編集権限を自動付与しないと明記されています。

共有はGoogleドライブの共有設定、数式や見出しの編集可否は保護設定です。2つを同じものとして扱わないでください。

5. 既存の別保護を上書きすると、入力欄まで編集できなくなる

sheet.protect() は、シートがすでに保護されている場合、その既存保護を返します。説明文・入力可能範囲・編集者をそのまま変更すると、別の担当者が作った保護を上書きします。

このコードは説明文 事務自動化ラボ: 入力欄以外を保護 を識別子として使い、2回目以降は同じ保護だけを更新します。別のシート保護がある場合は上書きせず停止します。さらに、A2:D1000などの入力欄と重なる範囲保護がある場合も、「入力できる」と誤って報告しないよう停止します。

6. 対象オーディエンスは、編集者一覧とは別に残る

Google Workspaceでは、個別ユーザー・グループ・ドメイン権限とは別に、対象オーディエンスが保護部分の編集者になっている場合があります。getEditors() だけを見ても出てこないため、個別編集者を外して終えると権限が残ります。

コードでは getTargetAudiences() を別に取得して外し、点検時にも0件であることを確認します。対象オーディエンスを意図的に残したい運用には、このコードをそのまま使わないでください。

点検のしかた

保護設定 > 現在の設定を点検する を押すと、次を確認します。

  • 識別用の説明が付いたシート保護が1件だけあるか
  • 別用途のシート保護や、入力欄と重なる範囲保護がないか
  • 警告だけの設定になっていないか
  • 入力可能範囲がコード先頭の inputRanges と一致するか
  • ドメイン全体の編集が残っていないか
  • 保護部分の編集者が所有者・指定管理者だけか
  • 対象オーディエンスが残っていないか

「問題なし」と出ても、ファイルの共有設定までは点検していません。また、Googleグループの入れ子や組織の共有ポリシーは管理者側の設定です。機密情報のアクセス制御を、このコードだけに任せないでください。これは誤編集を防ぐための仕組みです。

検証したこと

2026年8月31日に、GASの各APIを模したテスト環境で次を確認しました。

確認項目 結果
警告のみを解除し、編集制限へ切り替える PASS
A2:D1000・F2:F1000だけを入力可能にする PASS
実行者を先に追加し、所有者を残して不要編集者を外す PASS
対象オーディエンス権限を外し、監査でも残存を検出する PASS
ドメイン全体の編集を無効にする PASS
初回だけ保護を作り、再実行では同じ保護を更新する PASS
別のシート保護・入力欄と重なる範囲保護を上書きせず停止する PASS
同名保護の重複、シート名違い、一般編集者の実行で安全停止する PASS
点検メニューが許可外編集者・警告のみ・ドメイン編集・入力範囲差分を検出する PASS

コード全文はJavaScriptとして読み込み、Google Apps Script固有APIをモックに差し替えて実行しました。ブラウザ上の別アカウントによる編集可否は、共有相手と権限構成で変わるため自動化していません。導入先で最後に「一般編集者が入力欄を編集でき、数式セルは編集できない」を確認してください。

受付や集計も続けて自動化する場合

入力元がGoogleフォームなら、Googleフォームの受付を自動化する に、自動返信・担当者通知・受付台帳のコードを置いています。

入力した日報を担当者・案件ごとに集計したい場合は、スプレッドシートの日報を月報へ自動集計する が続きです。どちらも共有シートで使う場合は、今回の保護設定を先に決めておくと、数式列を上書きされにくくなります。

まとめ

共有スプレッドシートの事故は、編集者の注意力よりも、編集できる範囲が広すぎることから起きます。

シート全体を保護し、入力欄だけを例外にする。警告だけで済ませず、編集者とドメイン権限を明示的に絞る。再実行時は保護を増やさず、既存設定を点検して更新する。この3つまで揃えて、初めて運用できる保護になります。

← 記事一覧へ