[Salesforce] 「監査項目の設定を有効化」の落とし穴


かなり特殊なケースですが、監査項目の設定を有効化すると思わぬ副作用が起こることがあります。
この特殊なケースが実際に起きてしまい、原因調査に時間を費やしてしまいました。
同じような経験をしている方に少しでもお役に立てばと思い、今回得られた知見をまとめます。
Spring'21時点の内容で記事を書いています。

TL;DR

  • 監査項目の設定権限は「組織の権限」の他に「プロファイル(権限セット)による権限」があります。プロファイルによる権限がないと、データローダ等で作成日などの監査項目を設定できませんが、Apexを使用するとプロファイルによる権限がなくても設定できてしまいます。
  • Apexで明示的に監査項目に値を設定しなくとも、インスタンスに監査項目が設定されることがあります。
    • SObject.cloneメソッド(第三引数をtrueにした場合)
    • StandardController.getRecordメソッド(Visualforceで監査項目を使用している場合)
    • 監査項目を含むクエリ
    • トリガコンテキスト変数(after insertのTrigger.newなど)
  • Apexで下記のような処理を行っている箇所は要注意です。
    • 既存レコードのIDをnullにしてinsertを行っている
    • レコードをclone(false,true,true)(第三引数がtrue)で複製し、insertを行っている(cloneメソッド)

はじめに

監査項目とは

監査項目とは、作成日、作成者、最終更新日、最終更新者のことを指します。
通常、これらの値をユーザが設定することができません。
監査項目の設定を有効化することで、insert時のみ監査項目に値を設定することができます。
具体的には作成日に過去の日付を設定してレコードを作成したりできます。
監査項目の設定は、旧システムの元の作成日のままデータ移行を行うときなどに使用します。

監査項目を設定したレコードの作成手順

  1. UIをClassicに切り替える
  2. 設定画面をひらく
  3. クイック検索で「ユーザインターフェース」と入力し、ユーザインターフェースをクリックする
  4. 「「レコードの作成時に監査項目を設定」および「無効な所有者のレコードを更新」ユーザ権限を有効化」にチェックを入れる
  5. 権限セットを作成し、システム権限の「レコードの作成時に監査項目を設定」にチェックを入れる
  6. データ移行を行うユーザに上記の権限セットを割り当てる
  7. データローダでレコードを作成する
  • 標準のUIから監査項目を設定することはできません。
  • データローダを使用するときは、「すべてのデータの編集」権限が必要です(権限がないと監査項目へのマッピングができない)。
  • データ移行が終わったら、必ず「「レコードの作成時に監査項目を設定」および「無効な所有者のレコードを更新」ユーザ権限を有効化」のチェックを外しましょう。

権限がないと監査項目に値が設定できない例

下記の場合、権限がないユーザは監査項目に値を設定できません。

データローダのエラー

標準 REST APIのエラー

Execute Anonymouseのエラー

監査項目設定の落とし穴

権限がないのに監査項目に値が設定できてしまう

ApexでInsertを実行した場合、「レコードの作成時に監査項目を設定」の権限がないユーザでも監査項目に値が設定できてしまいます(Execute Anonymouseを除く)。
下記のようなインスタンスは監査項目に値を持っています。これらのインスタンスを使用して不適切な方法で処理を行っていると、監査項目を有効化したときに思わぬ副作用が発生します。

  • 監査項目をクエリしたレコード
  • Trigger.newなどのトリガコンテキスト変数
  • 監査項目を使用したVisualforceの拡張コントローラで、StandardControllerのgetRecordメソッドで取得したレコード

不適切な処理の例は下記になります。

  • ① 既存のレコードのIDをnullにしてInsertしてしまう
  • ② レコードをclone(false,true,true)(第三引数がtrue)で複製し、insertしてしまう

例① 既存のレコードのIDをnullにしてInsertしてしまう

オブジェクトの複製はSObjectのcloneメソッドを使うべきですが、既存のレコードのIDにnullを設定することでもInsertできます。
元のレコードに監査項目が含まれていると、監査項目が設定されてしまいます。
「監査項目の設定を有効化」をチェックにしたまま上記のinsert処理が実行されると、権限のないユーザでも監査項目が設定されてしまいます。


myControllerExtension.cls
public class myControllerExtension {
    private final Account acct;
    private final ApexPages.StandardController con;

    public myControllerExtension(ApexPages.StandardController stdController) {
        this.acct = (Account) stdController.getRecord();
        this.con = stdController;
        this.acct.Id = null; // ★悪例: 既存のレコードのIDをnullにして使い回す
    }

    public void copy() {
        acct.Description__c = 'VFで複製した取引先';
        acct.AcutualCreatedDate__c = Datetime.now();
        con.save(); // ★ここでInsertしています
    }
}
Test.page
<apex:page standardController="Account" extensions="myControllerExtension">
    <apex:form >
        <apex:inputField value="{!account.name}"/> <p/>
        <apex:outputField value="{!account.Industry}"/><p/>
        <!-- ★↓ここでStandardControllerが監査項目を取得しています -->
        <apex:outputField value="{!account.createddate}"/><p/>
        <apex:commandButton value="複製する" action="{! copy}"/>
    </apex:form>
</apex:page>

例② レコードをclone(false,true,true)(第三引数がtrue)で複製し、insertしてしまう

元のレコードに監査項目が含まれている状態で、SObjectのcloneメソッドの第三引数をtrueにすると、監査項目がコピーされます。
「監査項目の設定を有効化」をチェックにしたまま、上記で複製されたレコードをinsert処理が実行されると、権限のないユーザでも監査項目が設定されてしまいます。

OpportunityTrigger.trigger
trigger OpportunityTrigger on Opportunity (after update) {
    List<Opportunity> newList = Trigger.new;
    List<Opportunity> oldList = Trigger.old;
    Map<Id, Opportunity> newMap = Trigger.newMap;
    Map<Id, Opportunity> oldMap = Trigger.oldMap;

    List<Opportunity> nextOpps = new List<Opportunity>();
    for (Id objId : newMap.keySet()) {
        Opportunity newObj = newMap.get(objId);
        Opportunity oldObj = oldMap.get(objId);
        if (newObj.IsWon && !oldObj.IsWon) {
            // ★↓ここで第三引数をtrueにしてしまっているため、監査項目がコピーされてしまっている
            Opportunity nextOpp = newObj.clone(false, true, true);
            nextOpp.StageName = 'Prospecting';
            nextOpp.CloseDate = Date.today().addYears(1);
            nextOpp.OwnerId = UserInfo.getUserId();
            nextOpp.ActualCreatedDate__c = Datetime.now();
            nextOpps.add(nextOpp);
        }
    }
    insert nextOpps;
}

参考