dtwo Policy Store

Salesforce Guard Opportunity Pipeline Fields

Keeps revenue-pipeline moves human-approved.

Direction
ingress
Rego package
salesforce.ingress.guard_opportunity_pipeline
App
salesforce
Bundle
crm
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:92b4111bcfd5fade1e78e5600bffbebaccb0d78b7f7dcd3f3c431a98fd895721

salesforceopportunitypipelinerevenuehuman-approvalaccess-controlgovernanceingress

What this policy does

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

What it does

Keeps revenue-pipeline moves human-approved. It denies Salesforce update calls that modify the Opportunity pipeline fields — StageName, Amount, and CloseDate — unless the caller's IdP groups include sales-managers. The agent can still do useful Opportunity hygiene (notes, next steps, Description); it just cannot advance the stage, resize the deal, or slip the close date. A human consummates the move.

Because one generic Salesforce tool fronts every object, this policy is argument-shaped, not tool-shaped — it matches the update tool by name, then keys on its arguments to find the object and the fields being written:

  • Salesforce Hosted (updateSobjectRecord, updateRecord, updateSobjectRecordByRelationship): reads the sobject-name argument and, when it is Opportunity (case-insensitive), scans the body field map.
  • tsmztech (salesforce_dml_records): applies only when operation is update or upsert (compared case-insensitively and with surrounding whitespace stripped); reads objectName and scans each entry of records[].
  • smn2gnt (update_record, bulk_update_records): reads object_type and scans data (a single field map or an array of them).

Updates to any other object, and updates to other Opportunity fields, pass through unchanged. A matched update whose object name is missing — or an Opportunity update whose body is missing or unstructured — fails closed (denied), because the gateway cannot then confirm the write leaves pipeline fields untouched.

Compliance alignment

  • SOX — SoD / COSO Principle 10 & Rule 13a-15(f)(2)(ii) — separation of "initiate" from "approve" and transaction authorization: an autonomous agent can prepare an Opportunity but cannot itself authorize the stage/amount/close move that drives revenue recognition; a human in sales-managers does.
  • SOX — PCAOB AI human-in-the-loop — supports a draft-only posture for the agent on revenue-affecting records.
  • SOC 2 CC6.3 — supports role-based access, least privilege, and segregation of duties by fencing pipeline mutation behind an IdP group.
  • GDPR Art. 22 / CCPA-CPRA 11 CCR §7200 (ADMT) — supports keeping a human in the loop for a commercially significant automated decision (moving a deal's stage/value) rather than letting the agent finalize it unattended.

Why ingress

Field updates are writes with permanent, externally visible side effects — an Opportunity stage or amount change feeds revenue reporting and can trigger Flow automations (customer emails, Slack posts, ERP syncs). The violation is fully determined by the request arguments, so denying at ingress stops the change before it reaches Salesforce.

Tool name matching

Tool names are matched on the (lowercased) suffix, because the DTwo gateway prefixes each tool with the configured MCP server name and that prefix is not standardized:

  • Hosted: *-updatesobjectrecord, *-updaterecord, *-updatesobjectrecordbyrelationship
  • tsmztech: *salesforce_dml_records (gated on operation ∈ {update, upsert})
  • smn2gnt: *-update_record, *-bulk_update_records

The camelCase hosted names and the community tool names above are the GA / published names from the app landscape note; confirm the exact strings against your deployed server's tools/list with the dump-input debug technique before relying on this in production.

Argument shape

  • Hosted: object under sobject-name, fields under body (a field map).
  • tsmztech: object under objectName, operation under operation, records under records (an array of field maps).
  • smn2gnt: object under object_type, fields under data (a field map, or an array of them for bulk_update_records).

Field-key matching is case-insensitive and whitespace-insensitive, so stagename, StageName, STAGENAME, and "StageName " are all caught. The operation gate (tsmztech) and the object name are matched case- and whitespace-insensitively (leading/trailing spaces, tabs, and newlines are stripped before comparison), so "Opportunity " or "opportunity\n" cannot be used to dodge the Opportunity match.

The body/sobject-name key names above are verified for the hosted sobject-all server; the sibling sobject-mutations updateRecord arg shape is not separately verified in the landscape note. If a variant delivers the object under a different key, this policy sees no object name and fails closed (denies) rather than passing — confirm the shape against your server's schema.

Examples

Allowed (non-pipeline Opportunity field)

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "salesforce-updatesobjectrecord", "type": "tool" },
    "payload": {
      "name": "salesforce-updatesobjectrecord",
      "args": {
        "sobject-name": "Opportunity",
        "id": "006xx0000000001",
        "body": { "Description": "Left VM; following up Friday", "NextStep": "Send pricing" }
      }
    }
  }
}

allow = true, no reason.

Denied (pipeline move, non-manager)

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "salesforce-updatesobjectrecord", "type": "tool" },
    "payload": {
      "name": "salesforce-updatesobjectrecord",
      "args": {
        "sobject-name": "Opportunity",
        "id": "006xx0000000001",
        "body": { "StageName": "Closed Won", "Amount": 250000 }
      }
    }
  },
  "subject": { "claims": { "groups": ["sales-reps"] } }
}

allow = false, reason names the offending fields (amount, stagename).

Allowed (same call, caller is a sales manager)

The identical call with "groups": ["sales-managers"] in subject.claims is allowed — managers may move pipeline.

Composition

Companion Salesforce policies (see bundles/crm):

  • protect-contact-fields — the same object-scoped write guard for the Contact object (ownership, PII, consent). No overlap: this policy governs only Opportunity pipeline fields.
  • deny-escape-hatches — blocks apex_execute / restful / tooling_execute / salesforce_execute_anonymous, which could otherwise move pipeline via raw DML and bypass this argument-level check.
  • freeze-record-deletes — the delete-side companion.
  • role-gate-writes — the per-app least-privilege baseline.

Known limitations

  • Escape hatches bypass this check. Raw-code / raw-API tools (salesforce_execute_anonymous, apex_execute, tooling_execute, restful) can write Opportunity fields without going through a matched update tool. Pair with deny-escape-hatches.
  • Hosted updateRelatedRecord (sobject-mutations) not covered. Like protect-contact-fields, this policy matches the three named hosted update tools; the relationship-scoped updateRelatedRecord variant does not carry a directly-identifiable object argument and is not inspected.
  • Record creation is out of scope — this is an update-only guard. The policy only inspects update/upsert calls; it does not cover the create tools, so an agent can create a brand-new Opportunity with StageName, Amount, and CloseDate already set (e.g. a Closed-Won deal born at creation). This affects every family: hosted createSobjectRecord / createRecord, smn2gnt create_record / bulk_create_records, and tsmztech salesforce_dml_records with operation: "insert". Creation belongs to role-gate-writes / an object-allowlist policy, not to pipeline-move control — pair with those to fence Opportunity creation. Relatedly, a salesforce_dml_records call that omits operation entirely is treated as out of scope and passes; the tsmztech server itself requires the field, but do not treat this policy as the enforcement point for it.
  • Structured arguments only. If a server delivers the body/records as an opaque or stringified value rather than a JSON object/array, the field scan cannot read it. For that reason an Opportunity-targeted update whose body is present but unstructured fails closed (denied) rather than passing.
  • Suffix tool-name match. Any tool ending in one of the matched suffixes is inspected; if a non-Salesforce server exposed a colliding suffix it would be caught too. Narrow the match if that is a concern.
  • Group names are placeholders — replace sales-managers with your IdP's group name at import time. Missing/empty claims fail closed for the grant: a caller with no groups is never treated as a sales manager.

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.guard_opportunity_pipeline

# Deny-by-default: only the explicit allow rules below permit a request. Every
# tool that is not one of the recognised Salesforce update tools is allowed by
# the first allow rule, so this "default deny" governs only matched update calls.
default allow := false

# Opportunity fields whose modification moves the revenue pipeline (compared
# case-insensitively).
protected := {"stagename", "amount", "closedate"}

# Lowercased tool name; safe if resource/name is absent.
tool_name := lower(object.get(object.get(input, "resource", {}), "name", ""))

# Tool arguments; safe if payload/args is absent.
args := object.get(object.get(input, "payload", {}), "args", {})

# --- Tool-family detection ---------------------------------------------------
# One generic tool fronts every object, so we match the tool name then key on
# its arguments (argument-shaped, not tool-shaped).

# Salesforce Hosted update tools (sobject-name + body).
is_hosted_update if endswith(tool_name, "-updatesobjectrecord")

is_hosted_update if endswith(tool_name, "-updatesobjectrecordbyrelationship")

is_hosted_update if endswith(tool_name, "-updaterecord")

# tsmztech generic DML tool — in scope only for record-modifying operations.
is_tsmztech_update if {
	endswith(tool_name, "salesforce_dml_records")
	# Operation compared case-insensitively AND whitespace-trimmed, so
	# "update", "UPDATE", and "update " (trailing space) all count as in-scope —
	# a server that trims the enum before calling Salesforce cannot dodge the gate.
	lower(trim_space(object.get(args, "operation", ""))) in {"update", "upsert"}
}

# smn2gnt update tools (object_type + data).
is_smn2gnt_update if endswith(tool_name, "-update_record")

is_smn2gnt_update if endswith(tool_name, "-bulk_update_records")

is_matched_update if is_hosted_update

is_matched_update if is_tsmztech_update

is_matched_update if is_smn2gnt_update

# --- Normalised object name + body across the three argument shapes ----------

# Raw object-name argument, read from whichever key the matched family uses.
obj_raw := object.get(args, "sobject-name", "") if is_hosted_update

obj_raw := object.get(args, "objectName", "") if is_tsmztech_update

obj_raw := object.get(args, "object_type", "") if is_smn2gnt_update

# Object name compared case- AND whitespace-insensitively, so " Opportunity ",
# "opportunity", and "Opportunity\n" are all recognised as Opportunity — a
# server that trims before calling Salesforce cannot slip past an exact match.
obj_name := lower(trim_space(obj_raw))

# Present only when the (trimmed) object name is a non-empty string; a missing
# key or a whitespace-only value is treated as "object unknown" -> fails closed.
obj_present if trim_space(obj_raw) != ""

body_val := object.get(args, "body", null) if is_hosted_update

body_val := object.get(args, "records", null) if is_tsmztech_update

body_val := object.get(args, "data", null) if is_smn2gnt_update

targets_opportunity if obj_name == "opportunity"

# --- Field extraction --------------------------------------------------------
# The body may be a single field map (hosted body, smn2gnt single) or an array
# of field maps (tsmztech records[], smn2gnt bulk data[]).

# Field keys are lowercased AND whitespace-trimmed before comparison, so
# "StageName", "STAGENAME", and "StageName " (trailing space) all resolve to the
# protected key — mirroring the object-name normalisation so neither dimension
# can be slipped past with surrounding whitespace.
body_keys(b) := {lower(trim_space(k)) | some k in object.keys(b)} if is_object(b)

body_keys(b) := {lower(trim_space(k)) |
	some e in b
	is_object(e)
	some k in object.keys(e)
} if is_array(b)

body_keys(b) := set() if {
	not is_object(b)
	not is_array(b)
}

body_is_structured if is_object(body_val)

body_is_structured if is_array(body_val)

# Protected pipeline fields present in the update body.
offending := {f | some f in body_keys(body_val); protected[f]}

# --- Identity gate (placeholder group — replace at import time) --------------

caller_is_sales_manager if {
	subject := object.get(input, "subject", {})
	claims := object.get(subject, "claims", {})
	groups := object.get(claims, "groups", [])
	"sales-managers" in groups
}

# --- Decision ----------------------------------------------------------------

# Pass through everything that is not a matched Salesforce update tool.
allow if not is_matched_update

# Sales managers may move pipeline; they are exempt from this policy.
allow if {
	is_matched_update
	caller_is_sales_manager
}

# Everyone else: allow a matched update only when it is neither a pipeline move
# nor a call we cannot verify as pipeline-safe.
allow if {
	is_matched_update
	not caller_is_sales_manager
	not blocked
}

blocked if pipeline_move

blocked if malformed

# A pipeline move: an Opportunity update that touches a protected field.
pipeline_move if {
	is_matched_update
	targets_opportunity
	count(offending) > 0
}

# Fail closed: a matched update whose object cannot be determined, or an
# Opportunity update whose body is missing or unstructured (so we cannot confirm
# it leaves pipeline fields untouched).
malformed if {
	is_matched_update
	not obj_present
}

malformed if {
	is_matched_update
	obj_present
	targets_opportunity
	not body_is_structured
}

# --- Reasons -----------------------------------------------------------------

reasons contains msg if {
	not caller_is_sales_manager
	pipeline_move
	msg := sprintf("Moving an Opportunity's pipeline is restricted: this update changes %s. Route revenue-pipeline changes through a sales manager (IdP group \"sales-managers\") or complete the stage move in the Salesforce UI approval flow.", [concat(", ", sort([f | some f in offending]))])
}

reasons contains msg if {
	not caller_is_sales_manager
	malformed
	msg := "This Salesforce update could not be verified as pipeline-safe (missing object name or unstructured field body) and was denied. Resend the update with an explicit object name and a structured field body, or route the change through a sales manager or the Salesforce UI."
}

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

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