※本記事はプロモーションを含みます
目次
スプレッドシートで業務フォームや集計用シートを作成した際、利用者が計算式や見出しを誤って書き換えてしまう問題は後を絶ちません。手動で範囲保護を設定することも可能ですが、月次でシートが複製されたり、大量の拠点別シートを作成したりする現場では手動運用は現実的ではありません。なので、Google Apps Script(GAS)を使えば、シート全体を保護した上で特定セルだけを編集可範囲として開放する処理を自動化できます。ただし、単純にコードを記述するだけでは、行の挿入で保護範囲がずれたり、権限の設定ミスで他のユーザーが依然として全セルを編集できてしまったりといった壁にぶつかります。
編集可能範囲を限定してシートを保護するスクリプト
まずは、シート全体をロックしつつ指定した入力欄だけをユーザーが入力できるように開放する処理の実装例を示します。
const sheet = SpreadsheetApp.getActiveSheet();
const protection = sheet.protect().setDescription("入力以外ロック");
protection.setUnprotectedRanges([sheet.getRange("B2:B50")]);
このコードをいつ使うかというと、例えば毎月新しく生成される入力用シートの初期化時や、不特定多数の担当者が入れ替わりで数値を打ち込むテンプレートの配布時です。処理の順序としては、最初に sheet.protect() を呼び出してシート全体の保護オブジェクトを作成し、その直後に setUnprotectedRanges() の引数へ配列形式で保護から除外したい Range オブジェクトを渡しています。なので、指定範囲以外のすべてのセルが保護対象となります。
ただし、実務でこのコードをそのまま運用すると「範囲がずれる」という問題が発生します。というのは、固定の文字列として "B2:B50" を指定しているため、運用途中でユーザーや管理者が1行目にタイトル行を追加したり、途中に項目行を挿入したりすると、本来開放したかった入力領域と保護除外範囲の位置関係が崩れてしまうからです。たとえば、1行目に注記行が追加されると入力枠は B3:B51 に移動しますが、GAS側の指定が B2:B50 のままだと、B2 が編集可能で B51 がロックされるというちぐはぐな状態になります。
このズレを回避するには、範囲の指定に固定のA1ノテーション文字列を使わず、スプレッドシート上で「名前付き範囲(Named Range)」を定義しておき、GAS側からその名前付き範囲を取得して setUnprotectedRanges に渡す設計にするか、データが存在する行数を getLastRow() などで動的に計算して Range オブジェクトを構築する方法をとる必要があります。というのは、名前付き範囲を使っていれば、行や列が挿入されてもスプレッドシート側が自動的に範囲の定義を追従してくれるため、GAS側の修正なしで安全なロック状態を維持できるからです。
スクリプト実行者と所有者の違いで起きる書き込みエラー
上記のコードを実行した際、開発者自身の環境では正常に動くものの、一般の運用メンバーが実行した途端に権限関連のエラーが発生して動かなくなるケースがあります。というのは、Google Apps Script における実行権限と、スプレッドシートのファイル所有権(オーナー権限)の関係が深く影響しているからです。
GASの sheet.protect() メソッドは、呼び出したユーザーの Google アカウントの権限で実行されます。なので、スプレッドシートの共有権限において「編集者」権限しか持っていないユーザーがスクリプトを実行した場合、シート全体の保護ルールを作成または書き換える権限が不足し、処理が例外を発生させて停止します。ただし、仮に保護オブジェクトが作成できたとしても、その保護のオーナーはスクリプトを実行したユーザーになるため、シートの所有者であっても保護の設定を変更できなくなるという逆転現象が起きることもあります。
これを回避するためには、スクリプトの実行構造を工夫する必要があります。たとえば、ボタンに割り振ってユーザーに直接スクリプトを実行させるのではなく、Webアプリとしてデプロイして「実行ユーザー:自分(管理者)」として呼び出させる構造にすることや、シンプルトリガーではなくシニアリティを持ったアカウントで設置したインストール型トリガー(インストーラブルトリガー)を経由してバックグラウンドで保護処理を走らせる方法が有効です。
さらに、Google Workspace ドメイン内の運用では、組織外のアカウントや権限グループの設定によっても挙動が変わります。特に、ドメイン外の外部メンバーが参加する共有ドライブ内で処理を動かす場合、個別のファイル所有者概念が個人アカウントと異なるため、protect() を呼ぶ前に現在の実行ユーザーが対象シートの保護操作権限を有しているかを判定するロジックを挟むか、管理者アカウントに処理を集約させる設計が必須となります。
複数エリアの開放と動的な範囲指定における落とし穴
実務で使われるスプレッドシートは、入力枠が1箇所だけにまとまっていることは稀です。というのは、「顧客情報エリア」「発注数エリア」「備考欄」のように、離れた複数のセルや列に分散しているのが一般的だからです。なので、このような場合に setUnprotectedRanges を利用する際、GASの仕様に起因するハマりポイントが存在します。
離れた複数範囲を解放する場合、sheet.getRangeList() を使って範囲を取得したくなります。ただし、ここに罠があります。getRangeList() が返すのは RangeList オブジェクトであり、setUnprotectedRanges() が要求しているのは Range オブジェクトの「配列(Range[])」です。なので、setUnprotectedRanges(sheet.getRangeList([...])) と記述すると型不一致によりスクリプトがエラーで停止します。
複数範囲を正しく開放するには、以下のように個別に getRange() を呼んで取得した Range オブジェクトを配列に格納して渡さなければなりません。
const rangesToUnlock = [
sheet.getRange("B2:B50"),
sheet.getRange("D2:D50"),
sheet.getRange("F10:F15")
];
protection.setUnprotectedRanges(rangesToUnlock);
また、最終行が動的に変わる表に対して入力枠を設定する場合、sheet.getLastRow() を使って範囲を生成することが多いです。ただし、データが1件もない初期状態(見出し行しかない状態)で getLastRow() を呼ぶと、戻り値が 1 や 2 になり、getRange(2, 2, sheet.getLastRow() - 1, 1) のような計算を行っていると第3引数(行数)が 0 以下になって実行時エラーを引き起こします。
動的指定を行う際は、必ず getLastRow() の戻り値が想定最小行を満たしているか条件分岐を入れ、データ件数がゼロの場合でも最小限の入力領域(例えば最上段の1行分)が保護から除外されるようにガードロジックを組む必要があります。というのは、これを怠ると、新規データ作成時に一切入力ができないロック状態が完成してしまい、運用の現場を混乱させる原因となるからです。
既存保護ルールの重複とクリーンアップの必要性
GASで保護処理を組み込んだシート運用を続けていると、次第にスプレッドシートの動作が重くなり、セルの移動や値の入力に数秒の遅延が発生するようになることがあります。というのは、この原因の多くは、スクリプトが実行されるたびに古い保護設定が残ったまま、新しい protection オブジェクトが重複して生成され続けていることにあるからです。
sheet.protect() メソッドは、呼び出されるたびに新しい保護ルールをシートに追加します。ただし、過去に作成された同名の保護ルールが存在していても、それを自動で上書きしたり削除したりはしてくれません。なので、同じシートに対して何十もの保護ルールが蓄積され、スプレッドシートの内部で保護範囲の競合チェックが大量に発生し、パフォーマンスが著しく低下します。
この問題を回避するためには、新しい保護をセットする前に、既存のシート保護を検索してクリアするクリーンアップ処理を必ず実装する必要があります。具体的には、以下のように getProtections() を使ってシートに設定されている保護を取得し、ループ処理で削除してから再構築します。
const existingProtections = sheet.getProtections(SpreadsheetApp.ProtectionType.SHEET);
for (let i = 0; i < existingProtections.length; i++) {
existingProtections[i].remove();
}
ただし、ここでも注意点があります。というのは、削除しようとしている existingProtections[i] の作成者が自分でない場合や、自分にその保護の削除権限がない場合、remove() の呼び出しで権限エラーが発生するからです。なので、複数人で共有編集する環境では、自分が編集可能な保護ルールのみを判定して削除するか、エラーハンドリング(try-catch)を挟んで処理を継続させる設計が必要です。既存ルールを放置して上書き作成し続ける設計は、将来的な不具合の最大の温床となります。
他のユーザーに対する編集権限の明示的な制御
sheet.protect() と setUnprotectedRanges() を実行して「よし、これで入力欄以外は誰も触れないはずだ」と安心するのは時期尚早です。というのは、Google スプレッドシートの保護機能には、ドメイン設定やファイルの共有状況によって「保護された範囲であっても、ファイルの共同編集者にはデフォルトで編集許可が与えられてしまう」という挙動が存在するからです。
デフォルト状態の protect() は、「シート全体を保護するが、ファイルの編集権限を持っている既存ユーザーに対しては保護範囲の編集も許可する」という不完全なロック状態になるケースがあります。なので、本当に自分(管理者)以外の全ユーザーに対して入力欄以外の編集を拒否したい場合は、保護オブジェクトに対して編集者(Editors)の除外処理を明示的に呼び出す必要があります。
// 自身以外の編集者を保護設定から除外するパターン
protection.removeEditors(protection.getEditors());
if (protection.canDomainEdit()) {
protection.setDomainEdit(false);
}
上記のように removeEditors() を使って保護ルールの編集者リストから一般ユーザーを取り除き、さらに setDomainEdit(false) を実行して組織(ドメイン)全体の編集許可フラグをオフにする必要があります。というのは、これを行わないと、「保護を設定したはずなのに、他の社員が普通に見出しセルを削除できてしまった」という事故を防ぐことができないからです。
また、特定の管理者グループだけにロック領域の編集を許可したい場合は、removeEditors() で全員を一旦弾いた後に、addEditor(email) や addEditors(emails) を使って許可したいアカウントのみを個別に追加するロジックを記述します。なので、シートの保護と開放範囲の制御は、単に「セル範囲を指定する」ことと「誰に対してその制限を適用するのか」という権限管理の2つが揃って初めて機能するという点を理解しておきましょう。

