逆引きSpring'20の新しいFLSチェックの使い方

Spring'20でApexでCRUD/FLSチェックする新しい方法が正式リリースになりました。[^1]
以下の２つの機能です。

[^1]: https://releasenotes.docs.salesforce.com/ja-jp/spring20/release-notes/rn_apex.htm

* [stripInaccessible メソッドによるセキュリティの適用 | Apex 開発者ガイド | Salesforce Developers](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_classes_with_security_stripInaccessible.htm)
* [WITH SECURITY_ENFORCED を使用した SOQL クエリの絞り込み | Apex 開発者ガイド | Salesforce Developers](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_classes_with_security_enforced.htm)

微妙に使い方がわかりにくいので、パターン別にサンプルコードを書いてみました。なんの例外を投げるかわかりやすいようにcatchしています。例外スローじゃなくて無視したい場合とかは、コードを参考に書き換えてください。

なお、この記事は Salesforce 開発者向けブログキャンペーンへのエントリー記事です。[^2]アドベントカレンダー以来記事を書いてなかったのでイベントドリブン執筆です。

[^2]: https://developer.salesforce.com/jpblogs/2020/02/salesforce-developer-blog-campaign-2020/

# READ

## オブジェクト/項目に参照権限がなかったらエラーにしたい場合

`WITH SECURITY_ENFORCED`を使います。
オブジェクト/項目に参照権限がない場合、`QueryException`を投げます。
なお、WHERE句の項目は権限がなくてもエラーになりません。

```java
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]

[^3]: ドキュメントにどんな例外投げるか書いてないのですが、`NoAccessException`でした。

```java
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が返ります。

```java
Map<String, Set<String>> removedFields = decision.getRemovedFields();
System.assertEquals(new Set<String>{'LastName'}, removedFields.get('Contact'));
```

# CREATE

## オブジェクトに作成権限がない場合エラーにし、そうでない場合は作成可能な項目のみでinsertしたい場合

`stripInaccessible`を使います。
`AccessType.CREATABLE`にすること以外、READとだいたい同じです。

```java
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`が何かを返すので、そこでチェックしています。

```java
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とだいたい同じです。

```java
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();
```

## オブジェクト/項目に一つでも作成権限がない場合エラーにしたい場合

```java
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

## オブジェクトに削除権限がない場合にエラーにしたい場合

従来の方法で対応します。

```java
if (!Schema.sObjectType.Account.isDeletable()) {
  // オブジェクトに削除権限がない場合の処理
  throw CustomException('オブジェクトの削除権限がありません。');
}
Account[] accounts = [
  SELECT Id FROM Account
  WHERE Name = 'Test Account 1'
];
delete accounts;
```

# コードスキャナの対応は？

ISVパートナーはセキュリティレビューの際に[Partner Security Portal](https://security.secure.force.com/sourcescanner/)からコードスキャンをかけますが、上記の書き方でCRUD/FLSチェックにちゃんと通りました。（2020年2月27日時点）

# 終わりに

`WITH SECURITY_ENFORCED`と参照のときの`stripInaccessible`は便利ですね。

しかし、作成/更新は以下のようなインターフェースを期待してたんだけどな。どうしてこうなったのか。

```java
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]。こんな感じに↓

[^4]: `with sharing`はアノテーションじゃないけど

```java
@EnforceSecurity
public void insertAccount(Account acc) {
  insert acc;
}
```

結局今の仕様だと従来の方法で自作ユーティリティクラス使うのとコード量あまり変わらなくて残念。

# 参考）CRUD/FLSチェックをするDML処理カスタムクラス

残念ながら結局こんなクラスを作って使うことになりそうです。

```java
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;
    }
}
```