dtwo Policy Store

HubSpot Role-Gate Schema and Consent

Sits one privilege tier above hubspot/role-gate-writes: ordinary crm-writers can create and edit CRM records, but two higher-blast-radius write classes are…

Direction
ingress
Rego package
hubspot.ingress.role_gate_schema_consent
App
hubspot
Bundles
crmsoc2
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:636e03d146602ca6b48ed50fa6a70bc153d8f9acbe74dfc74cc5a3fc6d8585dd

hubspotrole-gate-schema-consentaccess-controlleast-privilegesegregation-of-dutiesconsentingresssoc2

What this policy does

Direction: ingress (tool_pre_invoke) Default: deny on match, allow otherwise Package: hubspot.ingress.role_gate_schema_consent

What it does

Sits one privilege tier above hubspot/role-gate-writes: ordinary crm-writers can create and edit CRM records, but two higher-blast-radius write classes are reserved for a dedicated admin group. Callers whose JWT groups claim contains hubspot-admins may invoke them; everyone else — including crm-writers — is denied. This preserves segregation of duties inside the write path. All other tools (reads and ordinary record writes) pass through untouched.

The two gated classes are:

  • Portal-schema mutations — property-definition tools that alter the portal for every user: the @hubspot/mcp-server local beta's hubspot-create-property and hubspot-update-property, plus shinzo's per-domain *_create_property and *_update_property stems (crm_create_property, crm_update_property, contacts_create_property, …).
  • Marketing-consent mutations — the shinzo consent-state tools communications_update_subscription_status, communications_update_preferences, and communications_subscribe_contact, whose arguments carry a contact email/id plus a subscription id and directly mutate GDPR/CAN-SPAM consent records.

The group check is fail-closed via object.get chains: if the caller has no subject, no claims, no groups claim, or a groups claim that is not a list of strings, the gated tools are denied. A missing claim never grants access.

The irreversible unsubscribe (communications_unsubscribe_contact) is a hard deny handled by hubspot/freeze-destructive-ops, not here — this policy gates the remaining reversible consent mutations.

Compliance alignment

  • SOC 2 CC6.3 — role-based access, least privilege, and segregation of duties: schema and consent changes require an explicit hubspot-admins membership that sits above the ordinary crm-writers write role, so the caller who edits records is not the same role that can redefine the schema or flip consent.
  • GDPR Art. 5(1)(b) — purpose limitation: subscription/preference changes are consent-affecting processing, kept under an admin role rather than available to any agent write path.
  • GDPR Art. 25; Art. 29 — data protection by default and processing only on the controller's authorization: consent-affecting and portal-schema mutations default to deny on the agent channel and are permitted only for an explicitly authorized group.

Why ingress

Property-definition and consent changes are writes with portal-wide, hard-to-reverse side effects — a new/edited property definition changes the schema for every HubSpot user, and a subscription-status or preference change mutates a compliance-relevant consent record. Denying at ingress means an unauthorized mutation never reaches HubSpot.

Tool name matching

The policy keys on the tool name only, matched case-insensitively by suffix against the lowercased input.resource.name. The DTwo gateway prefixes tool names with the configured server name (e.g. hubspot-mcp-crm_create_property), so suffix matching stays portable:

  • Schema suffixes: hubspot-create-property, hubspot-update-property (local beta), and _create_property / _update_property (the shinzo *_create_property / *_update_property wildcards). The _update_property suffix is a defensive addition — the landscape note verifies only *_create_property for shinzo (see Known limitations).
  • Consent suffixes: communications_update_subscription_status, communications_update_preferences, communications_subscribe_contact (the verified shinzo underscore forms), plus their kebab-case renderings for portability. The suffixes are specific enough that shinzo's read tool communications_get_subscription_status and the unsubscribe tool communications_unsubscribe_contact do not match.

Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production, and extend schema_suffixes / consent_suffixes if your server exposes additional schema or consent tools.

Argument shape

None assumed. The decision is made purely on the tool name; input.payload.args is never inspected, so the policy fails closed on the name alone even when args is empty or missing. The consent-tool argument shapes (contact email/id + subscription id) come from a community server and are not verified against a live tools/list — see Known limitations.

The identity check reads input.subject.claims.groups via object.get chains and expects an array of strings (the common IdP shape). Group comparison is exact and case-sensitive.

Examples

Allowed (ordinary record write by a crm-writer — SoD preserved)

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "hubspot-remote-manage_crm_objects", "type": "tool" },
    "subject": { "sub": "auth0|writer", "claims": { "groups": ["crm-writers"] } },
    "payload": { "name": "hubspot-remote-manage_crm_objects", "args": { "objectType": "contacts" } }
  }
}

allow = true, no reason. Record writes are gated by role-gate-writes, not here.

Allowed (schema mutation by an admin)

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "hubspot-create-property", "type": "tool" },
    "subject": { "sub": "auth0|admin", "claims": { "groups": ["hubspot-admins"] } },
    "payload": { "name": "hubspot-create-property", "args": { "name": "custom_field" } }
  }
}

allow = true, no reason.

Denied (consent mutation by a crm-writer, not an admin)

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "hubspot-shinzo-communications_update_subscription_status", "type": "tool" },
    "subject": { "sub": "auth0|writer", "claims": { "groups": ["crm-writers"] } },
    "payload": { "name": "hubspot-shinzo-communications_update_subscription_status", "args": { "contactEmail": "jane@example.com", "subscriptionId": "77" } }
  }
}

allow = false, reason = "HubSpot marketing subscription and consent preferences are admin-owned because they carry GDPR/CAN-SPAM obligations. …".

Composition

Layer this on top of the CRM write path:

  • hubspot/role-gate-writes — the tier below: gates ordinary record creates/updates for crm-writers. This policy adds the schema/consent tier for hubspot-admins on top; attach both.
  • hubspot/freeze-destructive-ops — hard-denies archive-class tools and the irreversible communications_unsubscribe_contact. That policy owns the unsubscribe; this one owns the reversible consent mutations.
  • hubspot/redact-pii — egress masking of contact PII.

See the bundles/crm bundle for the curated set.

Known limitations

  • Group name is a placeholder. Replace hubspot-admins (the admin_group constant in the Rego) with your IdP's real group name at import time. Group comparison is exact and case-sensitive.
  • Groups claim must be an array of strings. If your IdP emits groups as a single string or a namespaced custom claim (e.g. https://acme.com/groups), adjust caller_groups — until then, the gated tools are denied for every caller (fail-closed).
  • Consent-tool argument shapes are unverified. The consent tools come from the @shinzolabs/hubspot-mcp community server; their argument shapes (contact email/id + subscription id) are documented in the landscape note but not verified against a live tools/list. This policy does not depend on them — it decides on the tool name alone — but confirm the names with the dump-input technique before deploying.
  • _update_property is unverified for shinzo. The landscape note lists only *_create_property in the shinzo write inventory; a crm_update_property / contacts_update_property tool is not confirmed to exist there. The _update_property suffix is included defensively (the local beta proves property update is a real schema-mutation class, and shinzo's naming convention makes *_update_property the natural rendering). If your shinzo build never exposes such a tool the suffix is simply inert; confirm names with the dump-input technique before relying on it.
  • Gated list is a blocklist. New schema or consent tools added by a server upgrade are allowed (subject only to role-gate-writes) until added to schema_suffixes / consent_suffixes.
  • Suffix over-match. A generic ending like _create_property could match a non-HubSpot tool with the same suffix on a shared pipeline. Scope the pipeline to the HubSpot server, or narrow the suffixes, if that is a concern.

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 hubspot.ingress.role_gate_schema_consent

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

# Placeholder IdP group allowed to change portal schema and consent state.
# This sits one tier above the crm-writers write role. Replace with your
# IdP's real group name at import time.
admin_group := "hubspot-admins"

# Lowercased tool name, fetched with object.get so a missing resource/name
# yields "" (a clean non-match) instead of a silent rule failure.
tool_name := lower(object.get(object.get(input, "resource", {}), "name", ""))

# --- Portal-schema tools ---
# Property definitions alter the portal schema for every user. Covers the
# @hubspot/mcp-server local beta (hubspot-create-property / -update-property)
# and shinzo's per-domain *_create_property stems (crm_create_property,
# contacts_create_property, ...). Suffix match stays portable across the
# gateway's configured server-name prefix.
schema_suffixes := [
    "hubspot-create-property",
    "hubspot-update-property",
    "_create_property",
    # Editing an existing property definition is a portal-schema mutation too
    # (arguably higher-impact than creating one). The local beta exposes both
    # create and update; shinzo's {domain}_{operation}_{resource} convention
    # renders the update form as *_update_property. The landscape note verifies
    # only *_create_property for shinzo, so this suffix is a defensive/portable
    # addition — see Known limitations.
    "_update_property",
]

is_schema_tool if {
    some suffix in schema_suffixes
    endswith(tool_name, suffix)
}

# --- Marketing-consent tools ---
# shinzo communications_* tools that mutate subscription/consent records
# (GDPR/CAN-SPAM). The irreversible unsubscribe is a hard deny owned by
# freeze-destructive-ops; this policy gates the remaining reversible consent
# mutations. Underscore forms are the verified shinzo naming; kebab variants
# are included for portability. The suffixes are specific enough that the read
# tool communications_get_subscription_status and communications_unsubscribe_contact
# do not match.
consent_suffixes := [
    "communications_update_subscription_status",
    "communications_update_preferences",
    "communications_subscribe_contact",
    "communications-update-subscription-status",
    "communications-update-preferences",
    "communications-subscribe-contact",
]

is_consent_tool if {
    some suffix in consent_suffixes
    endswith(tool_name, suffix)
}

is_gated_tool if is_schema_tool

is_gated_tool if is_consent_tool

# Fail-closed groups lookup: missing subject, missing claims, or a missing
# groups claim all resolve to [] and grant nothing.
caller_groups := object.get(
    object.get(object.get(input, "subject", {}), "claims", {}),
    "groups",
    [],
)

# Exact, case-sensitive group match. If caller_groups is not iterable
# (e.g. the IdP emitted a string), this rule never fires — fail closed.
caller_is_admin if {
    some group in caller_groups
    group == admin_group
}

# Everything that is not a gated schema/consent tool passes for every caller —
# ordinary crm-writers keep their record-editing access (role-gate-writes).
allow if {
    not is_gated_tool
}

# Gated tools pass only for members of the admin group.
allow if {
    is_gated_tool
    caller_is_admin
}

reasons contains "HubSpot property definitions are admin-owned: creating or editing them changes the portal schema for every user. Editing property definitions through the agent channel is limited to members of the 'hubspot-admins' group. Manage properties in HubSpot Settings > Properties, or ask an administrator to add you to that group if you believe this is a false positive." if {
    is_schema_tool
    not caller_is_admin
}

reasons contains "HubSpot marketing subscription and consent preferences are admin-owned because they carry GDPR/CAN-SPAM obligations. Changing them through the agent channel is limited to members of the 'hubspot-admins' group. Manage subscription types in HubSpot Settings > Marketing > Email > Subscription Types, or ask an administrator to add you to that group if you believe this is a false positive." if {
    is_consent_tool
    not caller_is_admin
}

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)