Freeze Destructive Tableau Content Ops
Denies the irreversible content-mutation tools on the official tableau/tableau-mcp web server unless the caller's IdP token carries the placeholder group…
- Direction
- ingress
- Rego package
tableau.ingress.freeze_destructive_content- App
- tableau
- Bundle
- soc2
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:8d6b132ceba2fb069063ea88c98e98fe02d593d2b4d958b99b101323561c2f42
tableaufreeze-destructive-opsrecord-integrityingresssoc2
What this policy does
Direction: ingress (tool_pre_invoke)
Default: deny on match, allow otherwise
Package: tableau.ingress.freeze_destructive_content
What it does
Denies the irreversible content-mutation tools on the official tableau/tableau-mcp
web server unless the caller's IdP token carries the placeholder group
tableau-admins. All other tool calls (reads, queries, catalog/metadata, Pulse, view
renders) pass through unchanged.
The frozen surface is the server's destructive/mutation set: deleting a published data source or workbook, deleting an extract-refresh task, and rewriting a cloud extract-refresh schedule. Deleted workbooks and data sources go to the Tableau recycle bin and are recoverable for only a limited window before they are permanently gone; a silently stopped or rescheduled extract refresh is a data-integrity incident, not just an ops one — dashboards go stale while still looking live. An agent acting on a hallucinated instruction or an injected prompt must not be able to destroy content or quietly break a refresh, so this family is admin-gated for everyone else. The check runs at ingress, before the call reaches the MCP server, so a blocked delete never executes.
This IdP-claim gate sits on top of the server's own mutation guard
(src/tools/web/_lib/mutationGuard.ts): the server already enforces a site-admin gate,
a preview→confirm protocol, and a per-mutation audit record — but that guard is keyed on
Tableau roles and is the server's policy, not yours. The DTwo policy on IdP claims is
the only org-controlled gate, and it composes with (does not replace) the server guard.
Compliance alignment
- SOC 2 PI1.5 (Integrity of stored records — PF-06) — supports integrity of stored records by removing the agent's unilateral ability to delete BI content or silently break the extract refreshes that keep it accurate.
- GDPR Art. 5(1)(d) (Accuracy — anti-mass-corruption — PF-06) — supports accuracy by preventing mass-deletion of personal-data content and by blocking silent extract-refresh reschedules that would leave personal-data dashboards stale and inaccurate.
Tool name matching
The official server names tools kebab-case with no vendor prefix
(delete-workbook, confirm-delete-workbook), and the DTwo gateway prepends the
configured MCP server name (e.g. tableau-delete-workbook). Because the prefix is
deployment-specific, the policy matches on the distinctive suffix, case-insensitively,
for both the base tool and its separately-registered confirm- twin:
-delete-datasource/-confirm-delete-datasource-delete-workbook/-confirm-delete-workbook-delete-extract-refresh-task/-confirm-delete-extract-refresh-task-update-cloud-extract-refresh-task/-confirm-update-cloud-extract-refresh-task
Every destructive tool on this server has a confirm- twin registered as a separate
tool — a gate on delete-workbook that misses confirm-delete-workbook (or vice
versa) leaves the other half open, so both are enumerated explicitly. The suffixes are
distinctive enough not to collide with the read surface: list-extract-refresh-tasks
(plural) is not matched, and no read/catalog tool ends in one of these suffixes.
Underscores in the tool name are normalized to hyphens before matching, so a
snake_case-named community variant (delete_workbook) is gated too — over-matching is
the safe direction for a record-integrity freeze. The name is read from both the PARC
field (input.resource.name) and the legacy alias (input.payload.name) via object.get
chains, matched independently: a request missing the resource block, or one carrying
a malformed (non-string) value in either field, still cannot skip the match. Each field is
coerced to a lowercased, whitespace-trimmed string (a number, null, array, or object resolves
to the empty string), so a non-string value in one field can never suppress a genuine
destructive verb in the other, and leading/trailing whitespace or a trailing newline cannot
push a real destructive suffix out of reach of the endswith match.
Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production.
Argument shape
None. The decision uses only the tool name (input.resource.name, with the legacy
input.payload.name as fallback) and the caller's identity
(input.subject.claims.groups); arguments are not inspected. In particular, this policy
ignores the server's confirm boolean — it freezes the whole destructive family for
non-admins rather than only the confirm: true execution call. (If you instead want
agents to be able to stage a deletion for a human to confirm in the Tableau UI, use the
companion preview-only policy that keys on arguments.confirm; see Composition.)
Group membership is read via object.get-chained access to input.subject.claims.groups
and fails closed: a missing subject, missing claims, missing groups, or a non-array
groups value all mean "not admin", so the destructive call is denied.
Examples
Allowed — read tool, any caller
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "tableau-list-workbooks", "type": "tool" },
"payload": { "name": "tableau-list-workbooks", "args": {} }
}
}
allow = true, no reason.
Allowed — delete by an admin
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "tableau-delete-workbook", "type": "tool" },
"subject": { "sub": "admin@example.com", "claims": { "groups": ["tableau-admins"] } },
"payload": { "name": "tableau-delete-workbook", "args": { "workbookId": "wb-luid" } }
}
}
allow = true.
Denied — delete by a non-admin
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "tableau-confirm-delete-datasource", "type": "tool" },
"subject": { "sub": "analyst@example.com", "claims": { "groups": ["data-analysts"] } },
"payload": { "name": "tableau-confirm-delete-datasource", "args": { "datasourceId": "ds-luid", "confirm": true } }
}
}
allow = false, reason = "This Tableau content operation is blocked because it is hard to reverse (...)".
Composition
This policy is single-purpose: it freezes the destructive content family and nothing else. Useful companions from the Tableau candidate set:
- Preview-only deletes — allow the delete tools when
arguments.confirmis absent or false and deny only whenconfirm == true, so an agent can stage/report a deletion for a human to execute. Use this instead of this policy where a hard admin freeze is too disruptive; use it alongside to also gate the preview step behind a group. - Deny token management — deny
get-embed-token,revoke-access-token,reset-consentfor everyone; those mint/break credentials and are out of scope here. - Admin-insights lockdown — gate
query-admin-insights-ts-events,query-admin-insights-site-content,query-admin-insights-job-performance,get-stale-content-report, andlist-usersbehindtableau-admins(employee-monitoring data). - Egress PII redaction + image deny on
query-datasource/get-view-data/get-view-image— content-level controls this ingress freeze does not touch.
Known limitations
- Group names are placeholders — replace
tableau-adminswith your IdP's group name at import time. The gate readsinput.subject.claims.groups; confirm your IdP actually emits agroupsclaim (Auth0 and Entra ID both require explicit configuration) before relying on the admin exemption. With nogroupsclaim, the policy still fails closed: destructive calls are denied for everyone. - Tableau Next is a different product. The Salesforce-hosted Tableau Next server
(
analytics/tableau-next) uses disjoint snake_case tool names and is read-only as of GA (no delete/write tools), so this policy neither covers nor needs to cover it. A customer could run both products behind the gateway. - Community servers use unverified names. The community Python servers
(LokiMCPUniverse, hetpatel-11) advertise REST-backed write tools whose names are
unverified in the landscape note. The underscore-normalizing suffix match catches
delete_*-shaped variants, but verify with dump-input if you deploy one. - Name-mutation evasion is bounded by exact-name routing, not by this policy.
The match normalizes case, underscores→hyphens, and strips leading/trailing whitespace
and newlines, but it does not catch a name whose word separators or characters
differ from the registered tool — e.g. camelCase (
deleteWorkbook), a Unicode look-alike hyphen (U+2010), an extra internal separator (delete-data-source), or a trailing zero-width / non-whitespace invisible character (e.g. U+200B zero-width space, whichtrim_spacedoes not strip because it is a format character, not whitespace — so-delete-workbookis invisible on screen yet slips past theendswithsuffix match). The "trailing whitespace/newline cannot push a suffix out of reach" guarantee in Tool name matching above is precise: it holds for whitespace only, not for zero-width format characters. Any such string is only a bypass if the MCP server would route it to the real destructive tool, and the official and known community servers match tool names exactly: a mutated string names no routable tool and cannot execute a delete. Re-verify tool names with dump-input before trusting this for a server whose name-matching you have not confirmed. - Destruction-by-overwrite and passthrough are out of scope.
update-cloud-extract-refresh-taskis included because a silent reschedule is a data-integrity event, but the desktop toolset'sapply-workbook(writes workbook XML) runs local to Tableau Desktop and does not traverse the gateway. Content overwrite via re-publish is not modeled here. - The server's own mutation guard is separate. Its site-admin gate and preview→confirm protocol are keyed on Tableau roles, not IdP claims, and remain in force independently; this policy is the org-controlled layer on top, not a replacement.
update-cloud-extract-refresh-taskargument shape is unverified in the landscape note. This policy does not inspect arguments, so that does not affect enforcement — but a companionconfirm-argument policy would depend on the exact shape.
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 tableau.ingress.freeze_destructive_content
# Deny-by-default: only the explicit allow rules below permit the request.
default allow := false
# Placeholder IdP group allowed to run destructive Tableau content operations.
# Replace "tableau-admins" with your IdP's group name at import time.
admin_group := "tableau-admins"
# --- Destructive tool matching ---
# The official tableau/tableau-mcp web server names tools kebab-case with no
# vendor prefix (delete-workbook, confirm-delete-workbook) and the gateway
# prepends the configured MCP server name (e.g. tableau-delete-workbook), so
# the distinctive verb+noun appears as a suffix. Each destructive tool has a
# separately-registered `confirm-` twin; both the base and the twin are listed
# explicitly so gating one can never leave the other open.
# Verify the exact names on your gateway with the dump-input debug technique.
destructive_suffixes := [
"delete-datasource",
"confirm-delete-datasource",
"delete-workbook",
"confirm-delete-workbook",
"delete-extract-refresh-task",
"confirm-delete-extract-refresh-task",
"update-cloud-extract-refresh-task",
"confirm-update-cloud-extract-refresh-task",
]
# Tool name is read via object.get chains from BOTH the PARC field
# (input.resource.name) and the legacy alias (input.payload.name), so a
# request that somehow omits the resource block still cannot skip matching
# (red-team hardening: missing resource must not fail open).
# name_of coerces to a lowercased, whitespace-trimmed string. A missing OR
# non-string value (number, null, array, object) resolves to "" rather than
# leaving the rule undefined — an undefined name would make the suffix match
# undefined and skip matching entirely (fail-open). Leading/trailing whitespace
# and newlines are stripped with trim_space so a name padded with a trailing
# space or "\n" cannot slip past the endswith() suffix match (red-team
# hardening: whitespace must not evade the freeze).
name_of(key) := lower(trim_space(v)) if {
v := object.get(object.get(input, key, {}), "name", "")
is_string(v)
}
name_of(key) := "" if {
v := object.get(object.get(input, key, {}), "name", "")
not is_string(v)
}
resource_name := name_of("resource")
payload_name := name_of("payload")
# Both names are checked independently. Reading the two fields into a set and
# iterating would re-couple them; keeping separate branches means a malformed
# (non-string) value in one field cannot suppress a real destructive suffix in
# the other. Underscores are normalized to hyphens so a snake_case-named variant
# (delete_workbook) is still gated — over-matching is the safe direction for a
# record-integrity freeze.
is_destructive_tool if {
some suffix in destructive_suffixes
endswith(replace(resource_name, "_", "-"), suffix)
}
is_destructive_tool if {
some suffix in destructive_suffixes
endswith(replace(payload_name, "_", "-"), suffix)
}
# --- Admin gate ---
# Reads the groups claim through object.get chains so a missing subject,
# missing claims, missing groups, or non-array groups value fails closed:
# the caller is simply not an admin and the destructive call is denied.
caller_is_admin if {
claims := object.get(input.subject, "claims", {})
groups := object.get(claims, "groups", [])
# groups must be an array. Without this guard, `some group in groups`
# would iterate the VALUES of an object-typed groups claim
# (e.g. {"0": "tableau-admins"}) and grant the admin exemption — a
# fail-OPEN path that contradicts the documented "non-array groups fails
# closed" behavior. is_array makes a string, object, number, or null
# groups value all resolve to "not admin" (red-team hardening).
is_array(groups)
some group in groups
group == admin_group
}
# Allow any tool outside the destructive content family.
allow if {
not is_destructive_tool
}
# Allow destructive tools only for members of the admin group.
allow if {
is_destructive_tool
caller_is_admin
}
reasons contains "This Tableau content operation is blocked because it is hard to reverse: deleted workbooks and data sources sit in the recycle bin for only a limited window before they are gone for good, and a silently stopped or rescheduled extract refresh leaves dashboards stale while they still look live. Ask a member of your Tableau admin group (placeholder: tableau-admins) to run it, or ask your InfoSec team to add you to that group if you believe you should have access." if {
is_destructive_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)
Related policies
Airtable: Redact PII in Record Reads
Scans the responses of the Airtable record-read tools — the calls that return row fields values — and rewrites high-confidence PII shapes to a fixed…
Asana: Redact PII in Task & Comment Reads
On the Asana MCP read path, this transform scans the free-text business fields that ride back in task, comment/story, and status-update responses — notes,…
BigQuery: Redact PII in Query Results
Scans the content returned by BigQuery's result-returning tools and rewrites high-confidence PII shapes to fixed, non-recoverable redaction tokens before the…
Block Agent Email to External Recipients
Blocks agent-initiated Microsoft 365 email sends when any recipient address falls outside a corporate-domain allowlist.
Block BigQuery Exfiltration and Cross-Project Writes
Inspects the raw GoogleSQL string carried by BigQuery SQL tools and denies any statement that moves data out of the tenant's own project — even when the call…
bigqueryguard-warehouse-exportingresssqlexfiltrationsoc2pci-dssgdpr-ccpa
Block Bulk Export & External Staging (Snowflake)
Blocks Snowflake SQL-execution tool calls whose query text moves whole tables off the Snowflake perimeter — bulk export to cloud storage or a stage, and…
snowflakeguard-warehouse-sqlexportexfiltrationingresssoc2pci-dssgdpr-ccpa