Spring'20でApexでCRUD/FLSチェックする新しい方法が正式リリースになりました。1
以下の2つの機能です。
- stripInaccessible メソッドによるセキュリティの適用 | Apex 開発者ガイド | Salesforce Developers
- WITH SECURITY_ENFORCED を使用した SOQL クエリの絞り込み | Apex 開発者ガイド | Salesforce Developers
微妙に使い方がわかりにくいので、パターン別にサンプルコードを書いてみました。なんの例外を投げるかわかりやすいようにcatchしています。例外スローじゃなくて無視したい場合とかは、コードを参考に書き換えてください。
なお、この記事は Salesforce 開発者向けブログキャンペーンへのエントリー記事です。2アドベントカレンダー以来記事を書いてなかったのでイベントドリブン執筆です。
READ
オブジェクト/項目に参照権限がなかったらエラーにしたい場合
WITH SECURITY_ENFORCEDを使います。
オブジェクト/項目に参照権限がない場合、QueryExceptionを投げます。
なお、WHERE句の項目は権限がなくてもエラーになりません。
try {
  Account[] accounts = [
    SELECT Id, Name, (SELECT LastName FROM Contacts)
    FROM Account WHERE Name like 'Test'
    WITH SECURITY_ENFORCED
  ];
  return accounts;
} catch(QueryException e) {
  // オブジェクト/項目に参照権限がない場合の処理
  // ただし、他のエラーでもQueryExceptionになるので、そのままthrowした方がいいかもしれません。
  throw CustomException('オブジェクト/項目の参照権限がありません。');
}
オブジェクトに参照権限がない場合エラーにし、そうでない場合は参照可能な項目のみ取得したい場合
stripInaccessibleを使います。
オブジェクトに参照権限ががない場合、stripInaccessibleがNoAccessExceptionを投げます。3
SObjectAccessDecision decision;
try {
  decision = Security.stripInaccessible(
    AccessType.READABLE,
    [
      SELECT Id, Name, (SELECT LastName FROM Contacts)
      FROM Account WHERE Name like 'Test'
    ]
  );
} catch(NoAccessException e) {
  // オブジェクトに参照権限がない場合の処理
  throw CustomException('オブジェクトの参照権限がありません。');
}
// 参照可能な項目のみのレコードを取得
return decision.getRecords();
decision.getRecords() では参照権限がない項目を除いたレコードが取得できます。
decision.getRemovedFields() で除かれた項目も取得できます。戻り値はMap<String, Set<String>です。例えば、ContactのLastNameの参照権限がない場合、以下のようなオブジェクト名がキーで項目名のSetが値のMapが返ります。
Map<String, Set<String>> removedFields = decision.getRemovedFields();
System.assertEquals(new Set<String>{'LastName'}, removedFields.get('Contact'));
CREATE
オブジェクトに作成権限がない場合エラーにし、そうでない場合は作成可能な項目のみでinsertしたい場合
stripInaccessibleを使います。
AccessType.CREATABLEにすること以外、READとだいたい同じです。
Account[] accounts = new List<Account>();
accounts.add(new Account(
  Name='Test Account 1',
  Website='https://account1.example.com/',
  Phone='00-0000-0001'
));
SObjectAccessDecision decision;
try {
  decision = Security.stripInaccessible(
    AccessType.CREATABLE,
    accounts
  );
} catch(NoAccessException e) {
  // オブジェクトに作成権限がない場合の処理
  throw CustomException('オブジェクトの作成権限がありません。');
}
// 作成可能な項目のみのレコードを取得
insert decision.getRecords();
この場合、例えばWebsiteに作成権限がない場合、Name='Account1',Phone='00-0000-0001'のAccountがinsertされます。
オブジェクト/項目に一つでも作成権限がない場合エラーにしたい場合
前のとほぼ同じです。
一つでも作成権限がない項目がある場合、getRemovedFieldsが何かを返すので、そこでチェックしています。
Account[] accounts = new List<Account>();
accounts.add(new Account(
  Name='Test Account 1',
  Website='https://account1.example.com/',
  Phone='00-0000-0001'
));
SObjectAccessDecision decision;
try {
  decision = Security.stripInaccessible(
    AccessType.CREATABLE,
    accounts
  );
} catch(NoAccessException e) {
  // オブジェクトに作成権限がない場合の処理
  throw CustomException('オブジェクトの作成権限がありません。');
}
if (!decision.getRemovedFields().keySet().isEmpty()) {
  // 作成権限がない項目が一つでもある場合の処理
  throw CustomException('作成権限がない項目があります。');
}
// 作成可能な項目のみのレコードを取得
insert decision.getRecords();
もちろん最後はinsert accountsでもいいです。
UPDATE
オブジェクトに更新権限がない場合エラーにし、そうでない場合は更新可能な項目のみでupdateしたい場合
stripInaccessibleを使います。
AccessType.UPDATABLE にすること以外、CREATEとだいたい同じです。
Account[] accounts = [
  SELECT Id, Name, Website, Phone
  FROM Account
  WHERE Name = 'Test Account 1'
];
for (Account acc : accounts) {
  acc.Website = 'https://new.account1.example.com/';
}
SObjectAccessDecision decision;
try {
  decision = Security.stripInaccessible(
    AccessType.UPDATABLE,
    accounts
  );
} catch(NoAccessException e) {
  // オブジェクトに更新権限がない場合の処理
  throw CustomException('オブジェクトの更新権限がありません。');
}
// 更新可能な項目のみのレコードを取得
update decision.getRecords();
オブジェクト/項目に一つでも作成権限がない場合エラーにしたい場合
Account[] accounts = [
  SELECT Id, Name, Website, Phone
  FROM Account
  WHERE Name = 'Test Account 1'
];
for (Account acc : accounts) {
  acc.Website = 'https://new.account1.example.com/';
}
SObjectAccessDecision decision;
try {
  decision = Security.stripInaccessible(
    AccessType.UPDATABLE,
    accounts
  );
} catch(NoAccessException e) {
  // オブジェクトに更新権限がない場合の処理
  throw CustomException('オブジェクトの更新権限がありません。');
}
if (!decision.getRemovedFields().keySet().isEmpty()) {
  // 更新権限がない項目が一つでもある場合の処理
  throw CustomException('更新権限がない項目があります。');
}
// 更新可能な項目のみのレコードを取得
update decision.getRecords();
DELETE
オブジェクトに削除権限がない場合にエラーにしたい場合
従来の方法で対応します。
if (!Schema.sObjectType.Account.isDeletable()) {
  // オブジェクトに削除権限がない場合の処理
  throw CustomException('オブジェクトの削除権限がありません。');
}
Account[] accounts = [
  SELECT Id FROM Account
  WHERE Name = 'Test Account 1'
];
delete accounts;
コードスキャナの対応は?
ISVパートナーはセキュリティレビューの際にPartner Security Portalからコードスキャンをかけますが、上記の書き方でCRUD/FLSチェックにちゃんと通りました。(2020年2月27日時点)
終わりに
WITH SECURITY_ENFORCEDと参照のときのstripInaccessibleは便利ですね。
しかし、作成/更新は以下のようなインターフェースを期待してたんだけどな。どうしてこうなったのか。
Database.DMLOptions dml = new Database.DMLOptions();
// オブジェクトレベルのアクセス権チェックを適用
dml.securityHeader.enforceObjectCRUD = true;
// 項目レベルのアクセス権チェックを適用
// dml.securityHeader.enforceFLS = true;
// アクセス権のない項目を除く
dml.securityHeader.stripInaccessibleFields = true;
Database.insert(accounts, dml);
作成/更新でやりたいパターンってだいたい上のパターンくらいだと思うのでこれで十分じゃなかろうか。アクセス権ある項目だけ抽出するメソッドはそれはそれで使い道あるから、それはそれであっていいけど。
もしくはwith sharingみたいにクラスorメソッドレベルのアノテーションとかでもよかったなあ4。こんな感じに↓
@EnforceSecurity
public void insertAccount(Account acc) {
  insert acc;
}
結局今の仕様だと従来の方法で自作ユーティリティクラス使うのとコード量あまり変わらなくて残念。
参考)CRUD/FLSチェックをするDML処理カスタムクラス
残念ながら結局こんなクラスを作って使うことになりそうです。
public class SecureDML {
    public class SecureDMLException extends Exception {}
    /**
     * CRUD/FLSチェックを行うinsert
     */
    public static sObject[] secureInsert(sObject[] records) {
        return secureDML(records, AccessType.CREATABLE);
    }
    /**
     * CRUD/FLSチェックを行うinsert
     */
    public static sObject[] secureInsert(sObject record) {
        if (record == null) return null;
        return secureDML(new sObject[] {record}, AccessType.CREATABLE);
    }
    /**
     * CRUD/FLSチェックを行うupdate
     */
    public static sObject[] secureUpdate(sObject[] records) {
        return secureDML(records, AccessType.UPDATABLE);
    }
    /**
     * CRUD/FLSチェックを行うupdate
     */
    public static sObject[] secureUpdate(sObject record) {
        if (record == null) return null;
        return secureDML(new sObject[] {record}, AccessType.UPDATABLE);
    }
    /**
     * CRUD/FLSチェックを行うupsert
     */
    public static sObject[] secureUpsert(sObject[] records) {
        return secureDML(records, AccessType.UPSERTABLE);
    }
    /**
     * CRUD/FLSチェックを行うupsert
     */
    public static sObject[] secureUpsert(sObject record) {
        if (record == null) return null;
        return secureDML(new sObject[] {record}, AccessType.UPSERTABLE);
    }
    /**
     * CRUDチェックを行うdelete
     */
    public static void secureDelete(sObject[] records) {
        if (records == null || records.isEmpty()) return;
        if (!records[0].getSObjectType().getDescribe().isDeletable()) {
            throw new SecureDMLException('オブジェクトのアクセス権限がありません。');
        }
        delete records;
    }
    /**
     * CRUDチェックを行うdelete
     */
    public static void secureDelete(sObject record) {
        if (record == null) return;
        secureDelete(new sObject[] {record});
    }
    /**
     * CRUD/FLSチェックを行うinsert/update/upsert
     * アクセス権がない場合にInvalidAccessExceptionを投げる。
     */
    private static sObject[] secureDML(sObject[] records, AccessType accessType) {
        if (records == null || records.isEmpty()) {
            return records;
        }
        SObjectAccessDecision decision;
        try {
            decision = Security.stripInaccessible(
                accessType,
                records
            );
        } catch(NoAccessException e) {
            // オブジェクトにアクセス権限がない場合の処理
            String objName = records[0].getSObjectType().getDescribe().getName();
            throw new SecureDMLException('オブジェクトのアクセス権限がありません。:' + objName, e);
        }
        Map<String, Set<String>> removedFields = decision.getRemovedFields();
        if (!removedFields.keySet().isEmpty()) {
            String[] removedFieldNames = new String[] {};
            for (String objName : removedFields.keySet()) {
                for (String fieldName : removedFields.get(objName)) {
                    removedFieldNames.add(objName + '.' + fieldName);
                }
            }
            // アクセス権限がない項目が一つでもある場合の処理
            throw new SecureDMLException('アクセス権限がない項目があります。:' + String.join(removedFieldNames, ','));
        }
        // アクセス可能な項目のみのレコードを取得
        sObject[] stripedRecords = decision.getRecords();
        switch on accessType {
            when CREATABLE {
                insert stripedRecords;
            }
            when UPDATABLE {
                update stripedRecords;
            }
            when UPSERTABLE {
                upsert stripedRecords;
            }
        }
        return stripedRecords;
    }
}
- 
https://releasenotes.docs.salesforce.com/ja-jp/spring20/release-notes/rn_apex.htm ↩ 
- 
https://developer.salesforce.com/jpblogs/2020/02/salesforce-developer-blog-campaign-2020/ ↩ 
- 
ドキュメントにどんな例外投げるか書いてないのですが、 NoAccessExceptionでした。 ↩
- 
with sharingはアノテーションじゃないけど ↩
