Org Skills

The Salesforce access enforcement matrix

Supporting material for salesforce-security. Agents load it on demand; it ships inside the skill folder.

RawSource

Verified 2026-09 against https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_classes_keywords_sharing.htm and https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_classes_perms_enforcing.htm.

Three separate things

Apex runs in system mode by default. "Secure" means enforcing all three of these, and each needs a different construct:

What Construct Covers Does not cover
Record access (sharing rules, role hierarchy, OWD) with sharing on the class Which rows the user can see Object or field permissions
Object and field permissions (CRUD/FLS) WITH USER_MODE on SOQL, AccessLevel.USER_MODE on DML Which objects and fields the user may read or write Nothing beyond the operation it is on
Data already fetched in system mode Security.stripInaccessible Removes fields and records the user cannot access Does not re-run the query

The single most common mistake is assuming with sharing is enough. It is not: a class declared with sharing will still happily read a field the running user has no access to.

Class-level sharing

public with sharing class OrderService { }      // enforces sharing rules
public with sharing class OrderSelector { }     // selectors declare it too
public without sharing class BatchReconciler { } // system mode — justify it
  • with sharing — the default choice for anything reached from a user action, and for shared services and selectors.
  • without sharing — deliberate elevation only. Every use needs a comment saying why, and the method should be narrow enough that the elevation is contained.
  • The org standard recognises these two keywords only. inherited sharing exists on the platform but is not used here: the reviewer's question is always "which mode does this class run in", and an explicit answer is cheaper than tracing the caller. A class with no declaration at all defaults to system mode when entered directly, so the keyword is never optional.

Note: sharing declarations are not inherited by inner classes from the outer class in the way people expect — declare inner classes explicitly.

Query and DML access level

// Static SOQL
List<Account> accounts = [SELECT Id, Name FROM Account WITH USER_MODE];

// Dynamic SOQL — bind variables, never concatenation
String q = 'SELECT Id, Name FROM Account WHERE Industry = :industry';
List<Account> results = Database.queryWithBinds(
    q,
    new Map<String, Object>{ 'industry' => industry },
    AccessLevel.USER_MODE
);

// DML
Database.insert(newAccounts, AccessLevel.USER_MODE);
Database.update(changed, AccessLevel.USER_MODE);

AccessLevel.SYSTEM_MODE is available and explicit — use it where elevation is intended, so that the intent is visible at the call site rather than implied by its absence.

Migrating WITH SECURITY_ENFORCED

WITH USER_MODE supersedes WITH SECURITY_ENFORCED. It:

  • covers polymorphic lookup fields, which SECURITY_ENFORCED does not,
  • applies to DML through AccessLevel, giving one consistent model,
  • produces clearer exceptions.

Migration rule: replace, never combine.

// Before
List<Contact> cs = [SELECT Id, Email FROM Contact WITH SECURITY_ENFORCED];

// After
List<Contact> cs = [SELECT Id, Email FROM Contact WITH USER_MODE];

Writing both clauses on one query is redundant and signals that nobody was sure which applied.

Security.stripInaccessible

For the case where a query genuinely ran in system mode but the result goes back to a user:

SObjectAccessDecision decision = Security.stripInaccessible(
    AccessType.READABLE,
    systemModeRecords
);
return decision.getRecords();
  • Use AccessType.READABLE before returning, CREATABLE/UPDATABLE before DML on user-supplied data.
  • getRemovedFields() tells you what was stripped — useful in tests to assert enforcement actually happened.
  • It strips fields; it does not add back rows the user should have seen, and it does not make a system-mode query safe if the rows were the problem. If you can run the query in user mode, do that instead.

Where enforcement is easy to miss

  • @AuraEnabled methods — the entry point from any component, including from a guest user on a public site. Each one needs the full set.
  • Apex REST / SOAP endpoints — same, plus input validation.
  • Batch, Queueable and Schedulable — run as the user who enqueued them but commonly in system mode; be explicit about which access level each query should use.
  • Triggers — run in system mode by design. Enforcement belongs at the service entry point, not in the trigger.
  • Flows calling invocable Apex — the invocable method's class declaration decides sharing.

Testing enforcement

Assert it, do not assume it:

@IsTest
static void restrictedUserSeesNothing() {
    User u = TestDataFactory.restrictedUser();
    System.runAs(u) {
        Test.startTest();
        List<Order> result = OrderService.recentOrders(accountId);
        Test.stopTest();
        Assert.areEqual(0, result.size(), 'Restricted user should see no orders');
    }
}

A test that only runs as the admin proves nothing about enforcement — the admin passes every check.