dtwo Policy Store

Salesforce Cap Bulk Data Export

Blocks bulk PII extraction through Salesforce query tools by inspecting the free-text query arguments that are the real policy surface for these servers.

Direction
ingress
Rego package
salesforce.ingress.cap_bulk_export
App
salesforce
Bundles
crmsoc2hipaapci-dssgdpr-ccpa
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:753cf879feab22a8813df3861a2c69f898289c594370b2d06457d2e73c09f1cc

salesforcecap-bulk-exportdata-minimizationdlpingresssoc2hipaapci-dssgdpr-ccpa

What this policy does

Direction: ingress (tool_pre_invoke) Default: deny on match, allow otherwise Package: salesforce.ingress.cap_bulk_export

What it does

Blocks bulk PII extraction through Salesforce query tools by inspecting the free-text query arguments that are the real policy surface for these servers. A single generic query tool fronts every object, so this policy is argument-shaped, not tool-shaped — it reads the SOQL/SOSL string, not just the tool name.

Two surfaces are governed:

  • SOQL query tools (soql_query, executeQuery, querySobjects, run_soql_query, salesforce_query_records — suffix-matched case-insensitively). The policy applies anchored, case-insensitive regexes to the query string argument (SOQL is case-insensitive, so the patterns are too). When the query reads from the PII-heavy Contact, Lead, or Account objects, it must carry a trailing LIMIT clause of 1000 or fewer rows. A PII read with no LIMIT, or a LIMIT above 1000, is denied. Queries against other objects pass through — object pinning is query-allowlist's job, result volume is this policy's.
  • Org-wide SOSL search tools (find, executeSearch, searchSobjects, run_sosl_search, salesforce_search_all — suffix-matched). SOSL searches every field across every object, so it is a broad exfiltration surface even on a read-scoped server. These are denied unless the caller's IdP groups claim includes the placeholder group sales or support.

Fail closed: a matched SOQL tool with a missing/empty query argument, and a matched SOSL tool with a missing/empty search argument, are denied — an unverifiable scope is treated as unbounded. All other Salesforce tools and all non-Salesforce tools pass through unchanged.

Compliance alignment

  • SOC 2 CC6.7 — supports the restriction on transmission/movement/removal of information by capping how many PII records a single agent call can pull and by gating org-wide search.
  • HIPAA §164.502(b) / §164.514(d) — supports the minimum-necessary standard: an agent querying Contact/Lead/Account (which carry PHI in health-cloud orgs) is bounded to ≤1000 rows per call, and cannot fan out an unscoped org-wide search without a gated group.
  • PCI DSS 7.2.6 / 3.4.2 — supports restricting programmatic query access to stored account data by role (org-wide SOSL search is gated to the sales / support groups) and prevents bulk copy/relocation of card-adjacent data through the agent channel by capping per-call row volume on Contact/Lead/Account reads, so a single agent call cannot pull an unbounded PII set off the platform.
  • GDPR Art. 5(1)(c) — supports data minimisation on the agent read path by bounding bulk personal-data reads.
  • CCPA 11 CCR §7002 — supports proportionality: the volume an agent can extract per call is constrained relative to purpose.
  • CCPA/CPRA §1798.121 — supports limiting access to sensitive personal information by fencing org-wide field search behind sales/support.

Why ingress

A query's scope is fully determined by the request (the SOQL/SOSL string plus the caller's identity), so the cheapest and safest place to enforce a volume cap is before the call reaches Salesforce — an over-broad read never executes. Egress redaction (redact-pii) still masks the fields that a permitted read returns; the two compose (see Composition).

Tool name matching

Matches case-insensitively on the suffix of input.resource.name. The DTwo gateway prefixes tool names with the configured MCP server name (e.g. salesforce-soql_query), and that prefix is not standardized — suffix matching keeps the policy portable across the hosted and community dialects.

  • SOQL suffixes: soql_query (hosted sobject-all), executequery (sobject-mutations), querysobjects (sobject-deletes), run_soql_query (smn2gnt / beta-era), salesforce_query_records (tsmztech).
  • SOSL suffixes: find (hosted sobject-all), executesearch (sobject-mutations), searchsobjects (sobject-deletes), run_sosl_search (smn2gnt), salesforce_search_all (tsmztech).

Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production.

Argument shape

  • SOQL: the query text is read from input.payload.args.query (the key the hosted soql_query, community run_soql_query, and salesforce_query_records tools use). Some community servers expose the string under q or soql; if yours does, add that key to soql_query_text in policy.md.
  • SOSL: the search text is read from input.payload.args.search.
  • The sales/support exemption reads input.subject.claims.groups via object.get chains with an empty-array default, and requires the claim to be an array of strings (is_array + is_string guards). A missing subject, missing claims, missing groups claim, a bare-string groups, an object/map groups (even one whose values happen to equal "sales"/"support"), or an array whose gated entry is a non-string all deterministically fail closed (no exemption). Without the is_array guard a {"role":"sales"}-shaped claim would have iterated to the value "sales" and spoofed the exemption.

LIMIT parsing

The LIMIT value is extracted with a regex anchored to the end of the query (allowing a trailing OFFSET n and a FOR UPDATE|VIEW|REFERENCE clause). This is deliberate:

  • A LIMIT inside a child-relationship subquery ((SELECT … FROM Contacts LIMIT 5000)) sits before the outer FROM/LIMIT, so it is not mistaken for the outer row cap — a PII read whose only LIMIT is inside a subquery is treated as unbounded and denied.
  • A LIMIT n embedded in a WHERE string literal (WHERE LastName = 'LIMIT 5') is not at the end of the query, so it cannot be used to fake a bound.

Examples

Allowed — bounded PII read

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "salesforce-soql_query", "type": "tool" },
    "payload": {
      "name": "salesforce-soql_query",
      "args": { "query": "SELECT Id, Name FROM Contact WHERE Title = 'CFO' LIMIT 100" }
    }
  }
}

allow = true, no reason.

Denied — unbounded PII read

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "salesforce-soql_query", "type": "tool" },
    "payload": {
      "name": "salesforce-soql_query",
      "args": { "query": "SELECT Id, Email, Phone FROM Lead" }
    }
  }
}

allow = false, reason asks for a trailing LIMIT 1000 (or lower). Note a WHERE filter alone does not unblock the call — only a trailing LIMIT bounds row volume, so that is the only remediation the deny reason names.

Denied — org-wide SOSL outside sales/support

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "salesforce-find", "type": "tool" },
    "subject": { "sub": "auth0|eng@example.com", "claims": { "groups": ["engineering"] } },
    "payload": { "name": "salesforce-find", "args": { "search": "FIND {john} IN ALL FIELDS RETURNING Contact(Id, Email)" } }
  }
}

allow = false, reason points to a scoped SOQL query or a member of the gated groups.

Composition

Single-purpose: this policy only bounds read volume and org-wide search. It is designed to sit alongside:

  • salesforce/query-allowlist — pins the SOQL FROM object to an approved set but does not cap result volume; this policy adds the row cap. Together they bound what and how much an injected agent can pull per call.
  • salesforce/redact-pii — egress masking of the contact fields a permitted read returns. Ingress volume cap + egress field masking is defense in depth.
  • salesforce/read-only / salesforce/role-gate-writes — orthogonal write governance.

Known limitations

  • Group names are placeholders — replace sales/support with your IdP's group names at import time. The exemption reads input.subject.claims.groups (array of strings) and fails closed: no IdP, no claim, a bare-string groups, or an object/map groups means nobody is exempt from the SOSL deny (the rule requires is_array + is_string, so a {"dept":"sales"}-shaped claim cannot spoof the exemption).
  • Regex, not a SOQL parser. Object detection keys on the singular object name after FROM (Contact, Lead, Account). Child-relationship subqueries use the plural relationship name (FROM Contacts) and are intentionally not treated as PII reads — only the outer/primary object is evaluated. A PII object reached only through a parent-to-child subquery whose outer object is non-PII (e.g. … FROM Opportunity) is out of scope for this policy; pair with query-allowlist for object pinning. Custom objects holding account/contact data (Account_Archive__c) are not matched by name.
  • Unparseable object = pass-through here. A query with no recognizable FROM object is not caught by this policy (it is not a recognized PII read); rely on query-allowlist, which denies unparseable/non-allowlisted FROM objects, for that backstop.
  • query/search argument keys assumed. SOQL reads args.query, SOSL reads args.search. Servers that use q/soql or a different search key are not inspected until you add the key. A matched tool with the expected key missing fails closed (deny).
  • find is a generic suffix. The hosted SOSL tool is literally find, so the suffix match can also catch a find-suffixed tool on an unrelated MCP server on the same gateway. The failure mode is over-blocking (that search also needs sales/support), never under-blocking.
  • Escape hatches bypass this entirely. apex_execute, tooling_execute, restful, and salesforce_execute_anonymous can read records without issuing a matched SOQL/SOSL call; govern them with an escape-hatch deny.

Compliance note. This policy supports alignment with the cited framework controls on the MCP path only. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it.

Policy source (Rego)

package salesforce.ingress.cap_bulk_export

# Deny-by-default: only the explicit allow rules below permit the request.
default allow := false

# Maximum rows a PII read may return per call.
max_pii_rows := 1000

# SOQL query tools, matched by suffix (the gateway prefixes tool names with the
# configured MCP server name, which is not standardized). Verified against the
# Salesforce Hosted MCP GA references and the two community servers.
soql_suffixes := [
    "soql_query",               # hosted sobject-all
    "executequery",             # hosted sobject-mutations
    "querysobjects",            # hosted sobject-deletes
    "run_soql_query",           # community smn2gnt / beta-era hosted
    "salesforce_query_records", # community tsmztech
]

# Org-wide SOSL search tools — search every field across every object.
sosl_suffixes := [
    "find",                  # hosted sobject-all
    "executesearch",         # hosted sobject-mutations
    "searchsobjects",        # hosted sobject-deletes
    "run_sosl_search",       # community smn2gnt
    "salesforce_search_all", # community tsmztech
]

# Placeholder IdP groups exempt from the org-wide SOSL deny. Replace `sales` /
# `support` with your IdP's group names at import time.
sosl_groups := {"sales", "support"}

# Case-insensitive tool name; missing fields resolve to "" (never matches).
tool_name := lower(object.get(object.get(input, "resource", {}), "name", ""))

# Tool arguments; {} when payload/args are absent so lookups fail closed.
args := object.get(object.get(input, "payload", {}), "args", {})

is_soql_tool if {
    some suffix in soql_suffixes
    endswith(tool_name, suffix)
}

is_sosl_tool if {
    some suffix in sosl_suffixes
    endswith(tool_name, suffix)
}

# --- SOQL ---

# Trimmed query text; "" when missing (fails closed below).
soql_query_text := trim_space(object.get(args, "query", ""))

# The query's primary object is one of the PII-heavy standard objects. Keys on
# the singular object name after FROM (case-insensitive); the plural relationship
# names used by child subqueries (FROM Contacts) do not match.
from_pii if {
    regex.match(`(?i)\bfrom\s+(contact|lead|account)\b`, soql_query_text)
}

# Outer row cap: LIMIT anchored to the end of the query (optionally followed by
# OFFSET and a FOR UPDATE|VIEW|REFERENCE clause). Anchoring to the end means a
# LIMIT inside a subquery, or inside a WHERE string literal, is not mistaken for
# the outer cap.
soql_limit := n if {
    m := regex.find_all_string_submatch_n(`(?i)\blimit\s+(\d+)(?:\s+offset\s+\d+)?(?:\s+for\s+(?:update|view|reference))?\s*$`, soql_query_text, 1)
    count(m) == 1
    n := to_number(m[0][1])
}

soql_limit_ok if {
    soql_limit <= max_pii_rows
}

# --- SOSL ---

sosl_search_text := trim_space(object.get(args, "search", ""))

caller_in_sosl_group if {
    groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", [])
    is_array(groups)
    some group in groups
    is_string(group)
    sosl_groups[lower(group)]
}

# --- Allow rules ---

# Pass through anything that isn't a governed SOQL or SOSL tool.
allow if {
    not is_soql_tool
    not is_sosl_tool
}

# SOQL against a non-PII object passes through (volume is governed only for the
# PII-heavy objects; object pinning is query-allowlist's job).
allow if {
    is_soql_tool
    soql_query_text != ""
    not from_pii
}

# SOQL against a PII object is allowed only when it carries a trailing LIMIT of
# at most max_pii_rows.
allow if {
    is_soql_tool
    soql_query_text != ""
    from_pii
    soql_limit_ok
}

# SOSL is allowed only for callers in the sales/support groups, and only when a
# search term is actually present.
allow if {
    is_sosl_tool
    sosl_search_text != ""
    caller_in_sosl_group
}

# --- Deny reasons ---

reasons contains "This Salesforce query tool was called without a 'query' argument, so its scope cannot be verified and it is blocked. Provide the SOQL text you want to run. Contact your InfoSec team if this block is a false positive." if {
    is_soql_tool
    soql_query_text == ""
}

reasons contains "SOQL reads from the Contact, Lead, or Account objects must include a trailing 'LIMIT' clause of 1000 rows or fewer to prevent bulk PII extraction. Add 'LIMIT 1000' (or lower) to the end of your query. Contact your InfoSec team if this block is a false positive." if {
    is_soql_tool
    soql_query_text != ""
    from_pii
    not soql_limit_ok
}

# NOTE: this reason previously suggested "or narrow it with a WHERE filter" as an
# alternative. That was misleading — a WHERE filter without a trailing LIMIT is
# still denied (a filter does not bound row volume), so the remediation now names
# only the actually-unblocking fix: adding a trailing LIMIT.

reasons contains "Org-wide Salesforce search (SOSL) scans every field across every object and is restricted to callers in the 'sales' or 'support' groups. Use a scoped SOQL query against a specific object instead, or ask a member of those groups to run the search. Contact your InfoSec team if this block is a false positive." if {
    is_sosl_tool
    sosl_search_text != ""
    not caller_in_sosl_group
}

reasons contains "This Salesforce search tool was called without a 'search' argument, so it is blocked. Provide the SOSL search term you want to run. Contact your InfoSec team if this block is a false positive." if {
    is_sosl_tool
    sosl_search_text == ""
}

reason := joined if {
    count(reasons) > 0
    reason_list := sort([r | some r in reasons])
    joined := concat("; ", reason_list)
}

Canonical source: policy.md on GitHub · raw · raw on this site (.md)