dtwo Policy Store

Fence Gusto Compensation & Payroll Reads

Denies the highest-sensitivity Gusto read tools unless the caller's IdP-asserted groups include the placeholder group hr-payroll-admins.

Direction
ingress
Rego package
gusto.ingress.fence_comp_payroll_reads
App
gusto
Bundles
soc2gdpr-ccpa
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:15b4ad1bf1691934b530ae03c487d30184ab33c358353263ea6ba1de5b8cb42b

gustofence-hr-and-credit-scopecompensationpayrollingresssoc2gdpr-ccpa

What this policy does

Direction: ingress (tool_pre_invoke) Default: deny on the fenced read tools, allow otherwise Package: gusto.ingress.fence_comp_payroll_reads

What it does

Denies the highest-sensitivity Gusto read tools unless the caller's IdP-asserted groups include the placeholder group hr-payroll-admins. Every other Gusto tool (company/org lookups, employee directory reads, time tracking, utility tools) passes through unchanged.

The fenced tools cover four need-to-know payroll surfaces:

  • Salary / compensation: get_compensation, list_job_compensations
  • Full pay register: get_payroll, list_company_payrolls
  • Contractor financials: list_company_contractor_payments, get_contractor_payment, list_company_contractor_payment_groups, get_contractor_payment_group
  • Employment-action reads: list_employee_terminations, get_employee_rehire

Group membership is read with object.get(input.subject, "claims", {}) and the policy fails closed: a caller with no groups claim (or no subject at all) is treated as not exempt, so the read is denied. This narrows the agent channel to need-to-know payroll data (least-privilege logical access) without touching the web-UI or native-API paths, which the gateway cannot see.

Why ingress and not egress

These tools return regulated data on the way back, but the cheapest and most robust control is to stop the call before it reaches Gusto: an ingress deny means the salary/pay-register data is never fetched over the agent channel, so there is no response to redact and no partial-leak window. Egress redaction of a pay register is brittle (many nested numeric fields) and still incurs the upstream read.

Compliance alignment

  • SOC 2 CC6.1 — supports logical access security over protected assets by fencing the most sensitive payroll reads behind an IdP-group check; CC6.3 — supports role-based access / least privilege by restricting compensation and pay-register reads to a dedicated HR-payroll group.
  • SOX — ITGC access to programs & data — supports least-privilege access to financial systems (the payroll register is financial data feeding compensation expense) by gating the pay-register and contractor-payment reads to an authorized group; SoD (COSO Principle 10) — keeps broad agent identities out of the compensation surface.
  • GDPR Art. 5(1)(b) — supports purpose limitation by keeping compensation and employment-action data on a need-to-know footing on the agent channel; Art. 22 / CCPA 11 CCR §7200 (ADMT) — supports scoping the employment data (compensation, terminations, rehire) that could feed automated decision-making so it is reachable only by the HR-payroll role. (This is the Annex III employment-data fencing pattern for the MCP path.)

Tool name matching

Matching is case-insensitive on lower(input.resource.name) using endswith against the verified official snake_case tool names (from the Gusto MCP docs). The gateway prefixes tool names with the configured MCP server name (e.g. gusto-mcp-get_payroll), and that prefix is not standardized — suffix matching keeps the policy portable and survives a server prefix that itself contains gusto mid-name. Because Gusto's official server puts no vendor prefix on most tools and only carries gusto mid-name on two unrelated tools (list_gusto_companies, get_gusto_employee), anchoring on the full official suffix is the safe choice.

Fenced suffixes: get_compensation, list_job_compensations, get_payroll, list_company_payrolls, list_company_contractor_payments, get_contractor_payment, list_company_contractor_payment_groups, get_contractor_payment_group, list_employee_terminations, get_employee_rehire.

Argument shape

This policy inspects only the tool name and the caller's identity claims — it does not read input.payload.args, so it is immune to argument-key drift. Add companion policies (see below) if you also need to clamp per/include on the list tools it does allow.

Identity

Uses input.subject.claims.groups, read via object.get chains so a missing claim fails closed. The required group name hr-payroll-admins is a placeholder — see Known limitations.

Examples

Allowed — non-fenced read

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "gusto-mcp-list_company_employees", "type": "tool" },
    "payload": { "name": "gusto-mcp-list_company_employees", "args": { "per": 25 } }
  }
}

allow = true, no reason.

Allowed — fenced read by an HR-payroll admin

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "gusto-mcp-get_payroll", "type": "tool" },
    "payload": { "name": "gusto-mcp-get_payroll", "args": { "payroll_uuid": "abc" } },
    "subject": { "claims": { "groups": ["hr-payroll-admins"] } }
  }
}

allow = true, no reason.

Denied — fenced read, no HR-payroll group

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "gusto-mcp-get_compensation", "type": "tool" },
    "payload": { "name": "gusto-mcp-get_compensation", "args": { "employee_uuid": "e1" } },
    "subject": { "claims": { "groups": ["engineering"] } }
  }
}

allow = false, reason names the hr-payroll-admins group.

Composition

Single-purpose. Useful companions on a Gusto pipeline:

  • cap-bulk-export (PF-08) — clamp per and strip include=custom_fields on the directory list tools this policy still allows, throttling full-roster exfiltration.
  • An egress financial-identifier redactor (PF-02) — mask SSN/bank/routing patterns from any Gusto-server response, covering community/aggregator servers that surface bank data the official server does not.
  • deny-writes (PF-12/PF-09) — deny create_*/update_*/delete_* suffixes; a no-op on today's read-only official server but a guard against StackOne-style aggregators.

Known limitations

  • Group names are placeholders — replace hr-payroll-admins with your IdP's group name at import time. The policy will deny every fenced read until the group name matches what your IdP actually emits in the groups claim.
  • groups claim shape. The policy expects groups to be a JSON array of strings (per the DTwo identity schema). If your IdP emits group membership as a space-separated string or under a namespaced claim (e.g. https://acme.com/groups), no member ever matches and every fenced read is denied (fail-closed) — adjust the claim path before import.
  • Official-server names only. Suffixes are the verified official snake_case names. Community servers use kebab-case (get-payrolls, get-payroll) and StackOne uses aggregated hris_*-style names (unverified); this policy does not match those. Wire per-server pipelines with a matching name list if you front Gusto through a non-official server.
  • Suffix matching breadth (false positives). endswith on a bare suffix like get_payroll would also match a hypothetical unrelated tool whose name ends in that string. The official Gusto inventory has no such collision today; re-check if you add servers.
  • Suffix-extension evasion (false negatives). endswith fences a tool only when a listed name is the tail of the tool name. A server that appends text after a fenced base — a versioned or renamed variant such as get_payroll_v2 or get_compensation_history, or an aggregator verb like get_employee_compensation — is not fenced and passes through, exposing equivalent salary/pay data. The official Gusto inventory has no such name today, but re-audit the tool list whenever you add or upgrade a Gusto MCP server, and pin an explicit per-server name list for community/aggregator servers rather than relying on the official suffixes. Trailing whitespace or a trailing newline (get_payroll\n) is a degenerate case of the same class — it is appended text, so endswith no longer matches and the read passes through. This is not exploitable on a real gateway (the MCP router only dispatches a call whose name exactly matches a registered tool, so a whitespace-padded name never reaches the upstream get_payroll), but the isolated tests lock the behavior so a future refactor cannot regress it silently.
  • Argument shapes unverified. Entity-ID args shown in examples (payroll_uuid, employee_uuid, etc.) mirror the Gusto Embedded API but were not individually verified from the official tool schemas; this policy does not depend on them.

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 gusto.ingress.fence_comp_payroll_reads

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

# Highest-sensitivity Gusto read tools, matched by suffix. These are the verified
# official snake_case names from the Gusto MCP docs. Grouped by surface:
#   salary/compensation, full pay register, contractor financials, employment actions.
fenced_read_tools := [
    "get_compensation",
    "list_job_compensations",
    "get_payroll",
    "list_company_payrolls",
    "list_company_contractor_payments",
    "get_contractor_payment",
    "list_company_contractor_payment_groups",
    "get_contractor_payment_group",
    "list_employee_terminations",
    "get_employee_rehire",
]

# Placeholder IdP group; replace with your tenant's group name at import time.
required_group := "hr-payroll-admins"

# True when the request targets one of the fenced high-sensitivity reads. Matching is
# case-insensitive and by suffix so it survives the gateway's server-name prefix
# (including a prefix that contains `gusto` mid-name).
is_fenced_read if {
    name := lower(input.resource.name)
    some tool in fenced_read_tools
    endswith(name, tool)
}

# Caller's IdP-asserted groups. Read via object.get chains so a missing `subject`,
# missing `claims`, or missing `groups` yields an empty list rather than erroring —
# the exemption below then never fires, so the read fails closed (denied).
caller_groups := object.get(object.get(input.subject, "claims", {}), "groups", [])

is_exempt if {
    some g in caller_groups
    g == required_group
}

# Allow any tool that is not a fenced high-sensitivity read.
allow if {
    not is_fenced_read
}

# Allow a fenced read only for callers in the HR-payroll group.
allow if {
    is_fenced_read
    is_exempt
}

reasons contains "This Gusto tool exposes compensation, payroll, or employment-action data and is limited to members of the 'hr-payroll-admins' group. Route salary and pay-register questions through an HR-scoped pipeline instead. If your role should already carry this access, ask your IdP administrator to add you to the 'hr-payroll-admins' group." if {
    is_fenced_read
    not is_exempt
}

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)

Used in these guides