agentleFS
grzmolgrzmol/vibe-force/skills/sf-security-model/SKILL.md

sf-security-model

Skill2 starsChanged 2 months agoMITView on GitHub

Salesforce security model and secure-coding enforcement for Apex, LWC, and metadata - org/object/field/record layers, permission sets vs profiles vs permission set groups vs muting permission sets, CRUD and FLS enforcement with WITH USER_MODE, AccessLevel.USER_MODE, Security.stripInaccessible, and Schema describe checks, sharing keywords and Apex managed sharing __Share records, Lightning Web Security and CSP, SOQL injection, XSS, open redirect and CSRF fixes, Named Credentials and protected custom metadata for secrets, Shield Platform Encryption impacts, and Event Monitoring. Use when writing or reviewing @AuraEnabled controllers, triggers, dynamic SOQL, lwc:dom="manual" markup, permission set metadata, integration users, or when running vf-check analyzer or the sf-security-reviewer agent.

The file

We split this skill into the rules it gives an agent

6 rules. About 0 are common, 2 are distinctive, and 4 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 4

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Every @AuraEnabled method is a public web service: assume an attacker calls it with arbitrary parameters, regardless of what your component …

    line 145
  2. Distinctive:

    Every class touching data has an explicit sharing declaration.

    line 187
  3. Very specific:

    Every SOQL/SOSL/DML has an explicit access mode.

    line 188
  4. Distinctive:

    No string-concatenated user input in a query.

    line 189
  5. Very specific:

    No secrets in source; callouts use Named Credentials.

    line 191
  6. Very specific:

    No PII in System.debug; no APEX_CODE=FINEST trace flags left active.

    line 196

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Salesforce Security Model

When to use

  • Any Apex class that queries or writes data, especially @AuraEnabled, @InvocableMethod, @RestResource, global, or Visualforce controller entry points.
  • Building or reviewing permission metadata (permissionsets/, permissionsetgroups/, object/field permissions).
  • Dynamic SOQL/SOSL, Database.query, string-concatenated queries, lwc:dom="manual", innerHTML, redirects, HTTP callouts with credentials.
  • Wave 2 of the vibe-force workflow: sf-security-reviewer reads this skill before reviewing a diff; vf-check analyzer enforces the machine-verifiable subset.
  • Auditing the standing posture of a whole org rather than one change: skill sf-org-security-audit, which reuses the permission queries in references/permission-architecture.md.

API version 67.0 default-mode change (read this first)

config/vibe-force.defaults.json pins apiVersion 67.0. That version changed the Apex security defaults:

BehaviourAPI 66.0 and earlierAPI 67.0 and later
Database operation access modesystem mode (FLS/object perms ignored)user mode (FLS, object perms, sharing enforced)
Class with no sharing declarationresolved per inheritance chain / entry point, often without sharingwith sharing
Trigger body DML/SOQLsystem modeuser mode, which overrides the trigger's own without sharing context
Anonymous Apex, Connect in Apexwith sharingwith sharing
WITH SECURITY_ENFORCED in a SOQL SELECT in Apexallowed, with limitationsnot allowed - the compiler rejects it; use WITH USER_MODE

Consequences for vibe-force code: never rely on the default. Declare with sharing / inherited sharing / without sharing explicitly on every class with data access, and set an explicit access mode (WITH USER_MODE, WITH SYSTEM_MODE, as user, as system, AccessLevel.*) on every database operation so the code reads the same on any API version. Triggers cannot carry a sharing declaration: they always run without sharing, so delegate to a handler class (see skill sf-apex-development).

Security layer decision table

LayerControlsConfigured withEnforced in Apex by
Orglogin IP ranges, hours, MFA, session settingsSetup, security policiesnothing in code
Object (CRUD)create/read/edit/delete per objectpermission sets, permission set groups, profilesuser mode, Schema.DescribeSObjectResult.isCreateable/isAccessible/isUpdateable/isDeletable, stripInaccessible
Field (FLS)read/edit per fieldpermission sets (fieldPermissions)user mode, Schema.DescribeFieldResult, stripInaccessible
Recordwhich rows a user seesOWD, role hierarchy, sharing rules, manual shares, Apex managed sharing, restriction rulesclass sharing keyword + user mode
Apex/VF classwho may invoke the classclassAccesses / pageAccesses in permission setsruntime access check on @AuraEnabled and VF entry points
ClientDOM/script isolationLightning Web Security, CSPLWS sandbox, lightning/ui*Api (enforces FLS server-side)

Object/field permissions and sharing are independent and can coexist; where they conflict, object-level and field-level permissions take precedence over sharing rules.

Core patterns

1. Query in user mode (default choice)

public with sharing class ExpenseSelector {
    public static List<Expense__c> recent(Integer rowLimit) {
        return [
            SELECT Id, Name, Amount__c, Client__c, Date__c
            FROM Expense__c
            WHERE Date__c = LAST_N_DAYS:30
            WITH USER_MODE
            ORDER BY Date__c DESC
            LIMIT :rowLimit
        ];
    }
}

WITH USER_MODE processes every clause including WHERE, supports polymorphic fields (Owner, Task.WhatId), reports all FLS errors, and exposes them through QueryException.getInaccessibleFields(). It is the only clause-based option at our target version: in API 67.0 and later a SOQL SELECT in Apex cannot use WITH SECURITY_ENFORCED at all. Legacy (pre-67.0) code that still carries WITH SECURITY_ENFORCED must be migrated to WITH USER_MODE when its class is raised to 67.0; that clause also reported only the first error, skipped the WHERE clause, ignored polymorphic fields, and was never allowed in AppExchange packages.

2. DML in user mode, with error introspection

public with sharing class ExpenseWriter {
    public static void save(List<Expense__c> expenses) {
        List<Database.SaveResult> results = Database.insert(expenses, false, AccessLevel.USER_MODE);
        for (Database.SaveResult r : results) {
            if (!r.isSuccess()) {
                for (Database.Error e : r.getErrors()) {
                    System.debug(LoggingLevel.WARN, e.getStatusCode() + ' on ' + e.getFields());
                }
            }
        }
    }
}

Statement form: insert as user expenses; / update as system expenses;. On a user-mode DmlException, getDmlFieldNames() returns the fields that failed FLS.

3. Graceful degradation with stripInaccessible

SObjectAccessDecision decision = Security.stripInaccessible(
    AccessType.READABLE,
    [SELECT Name, BudgetedCost, ActualCost FROM Campaign WITH SYSTEM_MODE]
);
if (decision.getRemovedFields().get('Campaign')?.contains('ActualCost') == true) {
    // render the table without ActualCost instead of throwing
}
List<Campaign> safe = (List<Campaign>) decision.getRecords();

AccessType values: READABLE, CREATABLE, UPDATABLE, UPSERTABLE. Id is never stripped. AggregateResult is unsupported and throws. Use getModifiedIndexes() to detect that anything was stripped, and sObject.isSet('Field__c') to test a specific field.

4. Dynamic SOQL without injection

Map<String, Object> binds = new Map<String, Object>{ 'title' => '%' + userInput + '%' };
List<Personnel__c> rows = Database.queryWithBinds(
    'SELECT Id, Name, Title__c FROM Personnel__c WHERE Title__c LIKE :title',
    binds,
    AccessLevel.USER_MODE
);

Database.queryWithBinds (API 57.0+) resolves binds from a map, so the variables need not be in scope. Bind map keys are compared case-insensitively; duplicate case-variant keys throw QueryException. String.escapeSingleQuotes only helps inside single-quoted literals and is explicitly not the recommended defence. Field and object names cannot be bound: allowlist them (Set<String> of permitted API names) plus a describe accessibility check.

5. Record access beyond sharing rules: Apex managed sharing

Job__Share share = new Job__Share(
    ParentId = job.Id,
    UserOrGroupId = job.Recruiter__c,
    AccessLevel = 'Edit',
    RowCause = Schema.Job__Share.RowCause.Recruiter__c
);
Database.SaveResult sr = Database.insert(share, false, AccessLevel.USER_MODE);

Share objects are <Object>Share / MyObject__Share. Access levels: Read, Edit, All (All is managed-sharing only; None applies to AccountShare only). Apex sharing reasons (RowCause) are custom-object-only and survive owner changes; RowCause = 'Manual' shares are deleted when the owner changes. The object's OWD must not already be the most permissive setting, otherwise insert fails with FIELD_FILTER_VALIDATION_EXCEPTION. Details and the recalculation batch pattern: references/sharing-and-record-access.md.

6. Ship permission sets, not profiles

Grant all functional access through permission sets and compose them with permission set groups; use muting permission sets to subtract from a group rather than forking a permission set. Profiles stay minimal (Minimum Access - Salesforce plus license/defaults). Metadata shapes, retrieval caveats, and deployment ordering: references/permission-architecture.md.

7. Secrets never live in source

NeedUseNever
Outbound HTTP authNamed Credential + External Credential (callout:MyNC/path)hardcoded tokens, setHeader('Authorization', 'Bearer ...')
Package-scoped secretprotected custom metadata type in a namespaced managed packagecustom setting readable by users
Org-local operational secretprotected custom setting / custom metadata restricted by permission setApex constant, static resource
Crypto keys/IVsCrypto.generateAesKey, stored via the aboveliteral key/IV in Apex (ApexBadCrypto)

Credentials in ExternalCredential are encrypted by the platform with org-specific keys. vibe-force hooks scan writes for credential-looking literals and block them in standard and strict hook modes; vf-check analyzer flags ApexSuggestUsingNamedCred and ApexBadCrypto.

8. Client-side security

  • Lightning Web Security is the default architecture for orgs created in Winter '23 and later and GA for all orgs since Summer '23; it replaces Lightning Locker with per-namespace JavaScript virtualization. Components must not override window/document functions, must load libraries via lightning/platformResourceLoader from a static resource, and must not use inline <script>.
  • CSP for Lightning is default-src 'self', script-src 'self', object-src 'self', style-src 'self' https:, img-src 'self' http: https: data:, connect-src 'self', frame-src https:, frame-ancestors https:, font-src https: data:. Enable the stricter CSP org setting and develop against it. unsafe-inline and unsafe-eval are not blocked by CSP today but are rejected in AppExchange security review.
  • lightning/uiRecordApi, lightning-record-form, and the other Lightning Data Service wire adapters apply sharing, CRUD, and FLS for you. Prefer them over custom @AuraEnabled data plumbing (skill sf-lwc-development).
  • Every @AuraEnabled method is a public web service: assume an attacker calls it with arbitrary parameters, regardless of what your component sends.

Anti-patterns

Anti-patternWhy it failsFix
public class Svc { ... } with DMLsharing mode depends on API version and call chaindeclare with sharing (or inherited sharing for reusable services)
[SELECT ... FROM Account] in a class saved at 66.0 and callers assuming FLSpre-67.0 default is system modeadd WITH USER_MODE
Database.query('... WHERE Name = \'' + input + '\'')SOQL injectionDatabase.queryWithBinds with AccessLevel.USER_MODE
String.escapeSingleQuotes(input) on an unquoted boolean/number positionescaping does nothing outside quotestypecast or allowlist
with sharing used as an FLS controlsharing keywords set record access onlyadd user mode or stripInaccessible
stripInaccessible result discarded (insert original;)inaccessible fields still writteninsert decision.getRecords()
WITH SECURITY_ENFORCED anywhere in new Apexrejected by the compiler at API 67.0 and laterWITH USER_MODE
this.template.querySelector('div').innerHTML = serverHtmlXSS inside the componentrender with template directives, or sanitize before lwc:dom="manual"
PageReference(ApexPages.currentPage().getParameters().get('retURL'))open redirect (ApexOpenRedirect)allowlist relative paths
DML in a constructor or @AuraEnabled(cacheable=true) getterCSRF-on-GET (ApexCSRF)move state changes to an explicit POST-style action
System.debug(userRecord) with PII, APEX_CODE=FINEST in proddebug logs leak PII; FINEST logs every variable assignmentscrub logs, cap log level (skill sf-debugging-logs)
Integration user with Modify All Datano least privilege, blast radius on token leakdedicated integration permission set, object-scoped

Verification

# security-tagged static analysis (PMD + ESLint + Flow + Regex engines)
sf code-analyzer run --workspace . --rule-selector Security --view detail

# path-based CRUD/FLS analysis over Apex (Graph Engine)
sf code-analyzer run --workspace . --rule-selector sfge --target force-app/main/default/classes

# the gate vibe-force runs; fails at configured analyzerFailSeverity (default 3)
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" analyzer --changed

# prove enforcement at runtime with a least-privilege user
sf apex run test --tests SecurityEnforcementTest --synchronous --target-org vf-dev

# confirm what a permission set actually grants in the org
sf data query --query "SELECT SobjectType, PermissionsRead, PermissionsCreate, PermissionsEdit, PermissionsDelete FROM ObjectPermissions WHERE ParentId IN (SELECT Id FROM PermissionSet WHERE Name = 'Expense_App_User')" --target-org vf-dev

Runtime proof pattern (full version in references/crud-fls-enforcement.md): create a minimum-access user, assign only the intended permission set, wrap the call in System.runAs, and assert that an unauthorized call throws System.QueryException / System.DmlException / SecurityException rather than silently returning data.

PR security checklist

  1. Every class touching data has an explicit sharing declaration; triggers delegate to handlers.
  2. Every SOQL/SOSL/DML has an explicit access mode; WITH SYSTEM_MODE / as system / without sharing carries a comment naming the business reason.
  3. No string-concatenated user input in a query; dynamic field/object names allowlisted.
  4. @AuraEnabled / @RestResource / @InvocableMethod parameters validated and never used to choose objects or fields directly.
  5. No secrets in source; callouts use Named Credentials; endpoints are HTTPS (ApexInsecureEndpoint).
  6. No innerHTML/lwc:dom="manual" with server data; no inline script; libraries loaded from static resources.
  7. Permission sets updated in the same PR as new objects, fields, Apex classes, and pages; no profile edits.
  8. Tests assert negative access with System.runAs for at least one least-privilege persona.
  9. vf-check analyzer and vf-check local pass; any suppression has a rule selector, a limit, and a reason.
  10. No PII in System.debug; no APEX_CODE=FINEST trace flags left active.

References

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.