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…

On this page
  1. What it does
  2. Compliance alignment
  3. Why ingress
  4. Tool name matching
  5. Argument shape
  6. Examples
  7. Allowed (ordinary record write by a crm-writer — SoD preserved)
  8. Allowed (schema mutation by an admin)
  9. Denied (consent mutation by a crm-writer, not an admin)
  10. Composition
  11. Known limitations
  12. Policy source (Rego)
  13. Related policies
Direction
ingress (tool_pre_invoke)
Default
deny on match, allow otherwise
Rego package
hubspot.ingress.role_gate_schema_consent
App
HubSpot
Bundles
CRMSOC 2
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:636e03d146602ca6b48ed50fa6a70bc153d8f9acbe74dfc74cc5a3fc6d8585dd

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.

{
  "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)

Updated

to navigate to open