Warning: this integration was designed from secondary sources and has not been run against a real org. Every Salesforce behaviour it relies on is listed under Unverified and marked
# UNVERIFIED:in the code. Confirm it in a Developer Edition org (setup, probe, one allow and one deny per action family) before using its answers for anything.
toolpass authenticates as an External Client App acting as a read-only integration user, maps the
caller's email to a User row, and asks Salesforce's own permission objects with SOQL: UserRecordAccess
for one record, ObjectPermissions and FieldPermissions across the user's profile and permission
sets (ORed, the way Salesforce combines them), PermissionSet for system permissions and
PermissionSetAssignment for permission set membership. Every question is a GET /services/data/{version}/query; nothing is written and no user credential is ever used.
Since Spring '26 new connected apps cannot be created; use an External Client App (Setup, External Client App Manager):
- Create the app with OAuth enabled and the JWT bearer flow on. Upload the certificate whose
private key toolpass will sign with (
openssl req -x509 -newkey rsa:2048 -nodes -keyout toolpass.key -out toolpass.crt -days 365), set the OAuth scope toapi(Manage user data via APIs), and set the policy to Admin approved users are pre-authorized. - Create the integration user on the free Salesforce Integration licence with the Minimum Access – API Only Integrations profile. Give it a permission set with API Enabled, View Setup and Configuration and View All Users, and add that permission set (or its profile) to the app's pre-authorized list.
- Store the private key PEM (PKCS#1 or PKCS#8, unencrypted) in a file or environment variable. The
app's consumer key is
client_id.
For auth_flow: client_credentials enable the client credentials flow instead, set the app's
Run As user to the integration user and store the consumer secret as credential.
Read-only caveat. UserRecordAccess reportedly answers only for records the running user
(the integration user) can see, and ObjectPermissions/FieldPermissions rows are visible only for
objects the running user knows. For record.* answers on records the integration user does not own
or share, it may need View All on each object it is asked about, or View All Data (read-only
but org-wide). Confirm what is needed before relying on record checks; the probe repeats this
warning.
The JWT is signed RS256 with iss = consumer key, sub = username, aud = audience, exp =
now + 3 minutes. The access token it buys carries no expiry, so it is reused for token_ttl and
minted again 5 minutes before that. A 401 from the API drops the token and retries the call once with
a fresh one.
The JWT bearer flow signs with cryptography: install toolpass[crypto] (the Docker image has it).
- id: salesforce-acme
integration: salesforce
url: https://acme.my.salesforce.com
client_id: 3MVG9... # the app's consumer key
username: toolpass@acme.com # the integration user (JWT subject)
credential: file:/secrets/salesforce-toolpass.pem
api_version: v66.0
# auth_flow: jwt_bearer # or client_credentials (consumer secret in credential)
# audience: https://login.salesforce.com # https://test.salesforce.com for sandboxes
# match_field: Email # Username or FederationIdentifier
# token_ttl: 15m| Key | Meaning |
|---|---|
url |
the org's My Domain URL. The token endpoint is {url}/services/oauth2/token; API calls go to the instance_url the token response names when its host is url's host or under .salesforce.com, .force.com or .salesforce.mil, otherwise (or when it names none) to url |
client_id |
consumer key of the External Client App; the JWT issuer |
auth_flow |
jwt_bearer (default): RS256 JWT bearer grant with credential as the private key. client_credentials: consumer secret, acting as the app's Run As user |
username |
the integration user's Username; the JWT sub. Required for jwt_bearer |
credential |
PEM RSA private key or consumer secret, env: or file:; read at every mint |
audience |
the JWT aud: https://login.salesforce.com (default) or https://test.salesforce.com |
api_version |
REST API version, pinned, v66.0 style. Never discovered: a version bump is a config change |
match_field |
the User field the caller's email is compared to: Email (default), Username or FederationIdentifier |
token_ttl |
how long a minted token is reused (1m to 24h, default 15m) |
Under match_field: Email (the default) and Username, the first query is exact: SELECT Id, IsActive, Username, Email, FederationIdentifier, UserType, Name FROM User WHERE Username = '<escaped email>' LIMIT 2. Username is unique, so one row is the user and two rows are
user_ambiguous. Under Username no row is user_not_found; under Email no row leads to the
second query, ... WHERE Email = '<escaped email>' LIMIT 4 (under FederationIdentifier only this
query runs, against that field). Email is not unique in Salesforce: among two or three rows a single
active UserType = 'Standard' row wins; four rows mean the limit was hit and the rows are only a
subset of the matches, so nothing is picked; anything else is user_ambiguous, advising
match_field: FederationIdentifier. No row is user_not_found. A further query, SELECT IsFrozen FROM UserLogin WHERE UserId = '<Id>', detects frozen users; where UserLogin cannot be queried
(INVALID_TYPE/INVALID_FIELD) the identity records frozen=unknown, the user is still allowed
(user.active says "frozen users are not detected") and the probe warns. An inactive or frozen user
is denied for every action.
Nothing from the caller is ever interpolated raw. Record Ids must match
^[a-zA-Z0-9]{15}([a-zA-Z0-9]{3})?$, object and field API names ^[A-Za-z][A-Za-z0-9_]{0,79}$,
permission names ^Permissions[A-Za-z0-9]+$ and must appear in the cached
PermissionSet describe (GET /sobjects/PermissionSet/describe, cached one hour; an unknown
name is invalid_request and never reaches a query). Emails must parse with net/mail as a bare
address (no display name, no control characters) and are then escaped for a single-quoted literal:
\ first, then ', plus newline, carriage return and tab. Everything goes through
GET /query?q=<url-encoded>.
| Resource | Meaning |
|---|---|
record:<Id> |
one record by 15- or 18-character Id |
object:<ApiName> |
an sObject, e.g. object:Account, object:Invoice__c |
field:<Object>.<Field> |
one field, e.g. field:Account.Rating, field:Invoice__c.Amount__c |
permission:<PermissionsXxx> |
a system or app permission by PermissionSet field name, e.g. permission:PermissionsModifyAllData |
permset:<ApiName> |
a permission set without a namespace (PermissionSet.Name, NamespacePrefix = null) |
permset:<ns>__<ApiName> |
a managed package's permission set (Name and NamespacePrefix); both parts must be API names and the name may not contain another __ |
user:<email> |
the requesting user (for user.active); record:<UserId> is also accepted |
Resources take no ?query parameters.
| Action | Resource | What Salesforce is asked |
|---|---|---|
record.read |
record: |
UserRecordAccess.HasReadAccess |
record.edit |
record: |
UserRecordAccess.HasEditAccess |
record.delete |
record: |
UserRecordAccess.HasDeleteAccess |
record.transfer |
record: |
UserRecordAccess.HasTransferAccess |
record.share |
record: |
UserRecordAccess.HasAllAccess |
object.read |
object: |
any assigned profile or permission set has PermissionsRead |
object.create |
object: |
PermissionsCreate |
object.edit |
object: |
PermissionsEdit |
object.delete |
object: |
PermissionsDelete |
object.view_all |
object: |
PermissionsViewAllRecords |
object.modify_all |
object: |
PermissionsModifyAllRecords |
field.read |
field: |
any assigned profile or permission set has FieldPermissions.PermissionsRead |
field.edit |
field: |
FieldPermissions.PermissionsEdit |
system.permission |
permission: |
any assigned permission set (profiles included) has <PermissionsXxx> = true |
permset.assigned |
permset: |
a PermissionSetAssignment for the user with that PermissionSet.Name and NamespacePrefix |
user.active |
user: or record: |
the user is active and not frozen |
There is no record.create: records are created per object, so ask object.create with
object:<ApiName> (the error says so).
The queries, with <uid> the resolved user Id and every other placeholder validated as above:
- record:
SELECT RecordId, HasReadAccess, HasEditAccess, HasDeleteAccess, HasTransferAccess, HasAllAccess, MaxAccessLevel FROM UserRecordAccess WHERE UserId = '<uid>' AND RecordId = '<Id>' - object:
SELECT PermissionsRead, ..., Parent.IsOwnedByProfile, Parent.Name FROM ObjectPermissions WHERE SobjectType = '<obj>' AND ParentId IN (SELECT PermissionSetId FROM PermissionSetAssignment WHERE AssigneeId = '<uid>' AND PermissionSet.HasActivationRequired = false AND (ExpirationDate = null OR ExpirationDate > <now, YYYY-MM-DDThh:mm:ssZ>)). The sub-select keeps only assignments in force: session-based permission sets (in force only during an activated session) and expired time-bound assignments are excluded. When zero rows come back,GET /sobjects/<obj>/describe(cached one hour) tells a non-existent or invisible object (unknown) from one nothing grants (deny). - field: the same over
FieldPermissionswithField = '<obj>.<field>' - system:
SELECT Id, Name, IsOwnedByProfile FROM PermissionSet WHERE <PermissionsXxx> = true AND Id IN (<the same sub-select>) - permset:
SELECT Id FROM PermissionSetAssignment WHERE AssigneeId = '<uid>' AND PermissionSet.Name = '<name>' AND PermissionSet.NamespacePrefix = '<ns>'(or= nullfor an unprefixed name)
If the org answers INVALID_FIELD to the filtered sub-select (an API version without
ExpirationDate), the object, field and system queries run once more with the unfiltered
(SELECT PermissionSetId FROM PermissionSetAssignment WHERE AssigneeId = '<uid>'). That superset may
still produce a deny, but a grant found through it is unsupported ("could not exclude
session-based or expired assignments"), never allow.
Before an object, field or system answer, toolpass reads the user's permission set group
assignments (SELECT PermissionSetGroupId FROM PermissionSetAssignment WHERE AssigneeId = '<uid>' AND PermissionSetGroupId != null) and, when there are any, SELECT Id, DeveloperName, Status FROM PermissionSetGroup WHERE Id IN (...). A group whose Status is not Updated makes the answer
unknown until it is recalculated.
| Situation | Answer |
|---|---|
| the boolean asked about is true (record row, any object/field row through an assignment in force, any permission set row, an assignment exists) | allow, naming the profile or permission set where known (MaxAccessLevel is rendered only when it is one of None, Read, Edit, Delete, Transfer, All; otherwise "unknown") |
the boolean is false on every row; no ObjectPermissions row at all for an object whose describe answers 200; no permission set has the permission; no assignment (name and namespace) |
deny |
user inactive (IsActive = false) or frozen (UserLogin.IsFrozen) |
deny, for every action |
UserLogin not queryable (INVALID_TYPE/INVALID_FIELD) |
freezing is not evaluated: identity attribute frozen=unknown, the user is allowed, the probe warns |
no UserRecordAccess row |
unknown (resource_not_visible): the record is not visible to the integration user, or the object has no sharing settings |
no ObjectPermissions row and the object's describe is 404 |
unknown (resource_not_visible): the object does not exist or is not visible to the integration user |
no FieldPermissions row |
unknown (unsupported): required and system fields carry no rows |
| a permission set group not yet recalculated | unknown (unsupported) |
| a grant found only through the unfiltered assignment sub-select (the org rejected the activation/expiry filter) | unknown (unsupported): could not exclude session-based or expired assignments |
permission: name not in the PermissionSet describe; malformed resource (including a permset: with a bad namespace); record.create |
unknown (invalid_request) |
two users share the Username; two or three users share the email with no single active Standard one; four users share the email (query limit) |
deny (user_ambiguous); none |
400 INVALID_TYPE / INVALID_FIELD / MALFORMED_QUERY |
unknown (unsupported), naming the object or field |
401 (after one re-mint), 403 API_DISABLED_FOR_ORG, INSUFFICIENT_ACCESS or any other 403, token endpoint invalid_grant/invalid_client |
unknown (credential_rejected) |
403 REQUEST_LIMIT_EXCEEDED, 429 |
unknown (upstream_rate_limited) |
| 404 | unknown (resource_not_visible) |
| other 4xx, 5xx, transport | unknown (upstream_error); timeouts upstream_timeout |
Salesforce error bodies ([{"message": ..., "errorCode": ...}]) contribute only their errorCode
to a decision, and only when it matches ^[A-Z_]{1,64}$; anything else is rendered as "unknown
error" and the message is never copied.
A toolpass tool's Salesforce REST requests (observed actions)
are mapped by the route table in toolpass/integrations/salesforce/routes.py; toolpass routes salesforce prints it. The connection's url (the My Domain URL) is the host the table claims, all
of it.
| Request | Action and resource |
|---|---|
GET /services/data/vNN.N/sobjects/{Type}/{Id}, a binary field (Body, VersionData) |
record.read on record:<Id> |
PATCH .../sobjects/{Type}/{Id} |
record.edit on record:<Id>; record.transfer when the body sets OwnerId |
DELETE .../sobjects/{Type}/{Id} |
record.delete on record:<Id> |
POST .../sobjects/{Type}, composite/tree/{Type}, POST composite/sobjects (one type) |
object.create on object:<Type> |
GET .../query?q= or queryAll?q= with WHERE Id = '<Id>' alone |
record.read on record:<Id> |
| any other query | object.view_all on object:<Type>: it may return any record of the object |
GET .../sobjects/{Type}/describe |
object.read on object:<Type> |
GET .../sobjects/{Type}/updated, /deleted |
object.view_all on object:<Type> |
GET /services/data, the version's resources, /limits, /sobjects; the agent's own token and userinfo |
no check |
Queries are read by a small SOQL parser, and anything it cannot read exactly is refused: a
subquery or semi-join, a relationship field (Owner.Email, also in WHERE), TYPEOF, FOR,
WITH, USING, UPDATE VIEWSTAT, an alias, a character outside SOQL's, adjacent strings, any
parameter beside one q. Most users hold no View All on an object, so a general query is usually
refused; ask for one record by Id instead. Also refused:
- a body that names another record (a lookup, a nested record or reference by external id, child
records),
Idin the body,OwnerIdon create, records of several types, a body that is not JSON or names a field twice. A lookup is a 15- or 18-character Id in a field whose name ends inId(AccountId,ParentId,RecordTypeId: whether the user may use a record type has no check), or an Id-like value (letters and digits, 15 or 18 long, with a digit) in a custom or namespaced field (Manager__c), whose name does not show whether it is a lookup. Other standard fields are text, so"AccountNumber": "ACCT00012345678"is routed; - a record by external id, a related list (
/sobjects/{Type}/{Id}/Contacts), the object summary (/sobjects/{Type}, which lists recent records),/query/{locator}pages; - composite and batch requests that bundle several (
/composite,/composite/batch,/composite/graph, collection updates and deletes); - method overrides (
_HttpMethod,X-HTTP-Method-Override),;parameters, encoded slashes and dot segments in the path; - every other part of the instance: SOSL search, UI API, Tooling, Chatter, Connect, Bulk, invocable actions, Apex REST, file downloads.
Field-level security is not checked separately: a routed read or edit is the record's, whichever fields it names.
Every query counts against the org's daily API request allocation (a Developer Edition org has about 15,000 per day, unverified). After the token is cached a check costs two to six requests: the identity lookup (one or two queries), the frozen check, the permission set group check where relevant, the question itself, and for an object with no permission rows its describe (cached one hour). toolpass's decision cache is the mitigation; the probe reports the remaining allocation and warns under 10%.
Mints a token, reads GET /services/data/{version}/limits (DailyApiRequests into the summary,
a warning under 10% remaining), confirms the integration user with SELECT Id, Username, IsActive FROM User WHERE Username = '<username>' (a warning when missing or inactive), tries the frozen
check on the integration user itself (SELECT IsFrozen FROM UserLogin WHERE UserId = '<its Id>', or
SELECT IsFrozen FROM UserLogin LIMIT 1 under client_credentials) and warns "frozen users are not
detected: UserLogin not queryable" when the org rejects it, and fetches the PermissionSet
describe, which also proves the permission-name validation can work. It always warns about the View
All Data caveat.
- Validation rules, Apex triggers, flows, approval-process locks, record types and their picklist restrictions: an allowed edit may still be rejected.
- Restriction rules and scoping rules, which hide records after sharing is computed.
- Login hours, login IP ranges, session settings and MFA: an active user may still be unable to log in.
- Field-level security is not folded into record answers:
record.editsays nothing about which fields the user may edit; askfield.editseparately. - Everything depends on the integration user's own visibility: a record it cannot see, or an object it has no permission on, is unknown, not deny.
- Sharing sets, portal and community sharing, territory management and manual shares are only
reflected to the extent
UserRecordAccessincludes them.
Each item is marked # UNVERIFIED: in the code and must be confirmed in a Developer Edition org.
- The JWT bearer assertion must expire within 3 minutes; toolpass sets
exp= now + 3 minutes with noiatorjti. - The client credentials flow requires a Run As user on the External Client App and the token then acts as that user.
- The token response's
instance_urlnames the REST host; toolpass uses it (https only, no query or userinfo) as the API base and falls back tourl. That every org's REST host isurl's host or under.salesforce.com,.force.comor.salesforce.mil(any other host is ignored, logged at debug, andurlis used) is unverified, as is that the token response carries noexpires_in(hencetoken_ttl). - An expired or revoked session is a 401 with
errorCode: INVALID_SESSION_ID; toolpass re-mints on any 401, once. nextRecordsUrlis a/services/data/...path on the same instance; toolpass follows at most five pages.- The
PermissionSetdescribe lists one booleanPermissionsXxxfield per system and app permission, and those same names are the queryable filter fields. UserType = 'Standard'identifies full-licence users for the email disambiguation rule.UserLogin.IsFrozenis queryable by the integration user; a400 INVALID_TYPE/INVALID_FIELDis the only failure that turns the frozen state unknown (a 403 onUserLoginfails the identity lookup and the probe ascredential_rejected; ifUserLoginturns out to need Manage Users, this must change). ThatSELECT IsFrozen FROM UserLogin LIMIT 1(the probe's unfiltered form underclient_credentials) is accepted is also unverified.UserRecordAccess.MaxAccessLeveltakes only the values None, Read, Edit, Delete, Transfer and All; any other value is rendered as "unknown".PermissionSet.HasActivationRequiredandPermissionSetAssignment.ExpirationDateare filterable inside thePermissionSetAssignmentsub-select,ExpirationDate = nullmatches assignments without an expiry, and an org whose API version lacksExpirationDateanswersINVALID_FIELD(which triggers the unfiltered retry described under Actions).GET /sobjects/<name>/describeanswers 404NOT_FOUNDfor an object that does not exist, and also for one the integration user cannot see at all; toolpass treats both as unknown. Whether an object the integration user has no permission on describes as 200 is unverified.PermissionSet.NamespacePrefixis null for local permission sets and filterable through thePermissionSetrelationship ofPermissionSetAssignment; a developer name never contains two consecutive underscores, so the first__inpermset:<ns>__<Name>is the separator.Usernameis unique per org, so the exact lookup returning two rows is an anomaly reported asuser_ambiguous.UserRecordAccessreturns no row (rather than a row of false) for records the running user cannot see and for objects without sharing settings, and needs the exactUserId = ... AND RecordId = ...filter (this filter rule is confirmed; the empty-result behaviour is not).- Profile object and field permissions appear as
ObjectPermissions/FieldPermissionsrows whoseParent.IsOwnedByProfileis true, and permission set group grants appear through the group's aggregate permission set with muting already applied. - Required, system and some standard fields have no
FieldPermissionsrows, so zero rows is unknown, not deny. PermissionSetAssignment.PermissionSetGroupIdis set for group assignments andPermissionSetGroup.StatusreadsUpdatedonce recalculated; a400on that query skips the check.GET /limitsreportsDailyApiRequestswithMaxandRemaining; the Developer Edition allocation of about 15,000 per day.- Setup details: the External Client App JWT flow settings, pre-authorization by permission set, the
apiscope, the Salesforce Integration licence, the Minimum Access – API Only Integrations profile, and which of View All / View All Data the integration user needs for record answers.
Confirmed: new connected apps cannot be created since Spring '26 (External Client Apps only), and
UserRecordAccess must be filtered by one UserId and one RecordId.
python -m pytest tests/integrations/salesforce (in toolpass-py) runs against a fake Salesforce that verifies the JWT
bearer assertion (RS256 signature, iss/sub/aud, exp within 3 minutes) and the client
credentials form, hands out tokens with an instance_url, revokes them on demand, and answers
/query by parsing the SOQL FROM object and its literals (so a test address o'neil@example.com
must arrive as o\'neil@example.com), /limits, the PermissionSet describe and per-object describes (404 for unknown objects). The fake
insists that every assignment sub-select carries the activation/expiry filter with a current
timestamp, hides session-based and expired grants behind it, honours LIMIT, and matches permission
sets on name and namespace. tests/integrations/salesforce/test_routes.py routes the main reads,
writes and queries, checks every refusal above (Salesforce publishes no OpenAPI description of the
REST API to hold the table to), parses each resource with the integration's own parser and runs
routed requests through the check against the fake. Against a real org: complete the setup above in a Developer Edition org, configure a connection, run toolpass probe, then one allow and one deny for each of record.read, object.read, field.read,
system.permission, permset.assigned and user.active, and compare with what the user actually
sees in the UI. Work through the Unverified list while doing so.