---
name: Freeze Destructive Tableau Content Ops
tags:
  - tableau
  - freeze-destructive-ops
  - record-integrity
  - ingress
  - soc2
publishedAt: 2026-07-12
description: |
  # tableau / freeze-destructive-content

  **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

  ```jsonc
  {
    "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

  ```jsonc
  {
    "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

  ```jsonc
  {
    "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.confirm` is absent or
    false and deny only when `confirm == 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-consent` for 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`, and `list-users` behind `tableau-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-admins` with your IdP's group name at
    import time.** The gate reads `input.subject.claims.groups`; confirm your IdP actually
    emits a `groups` claim (Auth0 and Entra ID both require explicit configuration) before
    relying on the admin exemption. With no `groups` claim, 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, which
    `trim_space` does **not** strip because it is a format character, not whitespace — so
    `-delete-workbook​` is invisible on screen yet slips past the `endswith` suffix
    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-task`
    is included because a silent reschedule is a data-integrity event, but the desktop
    toolset's `apply-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-task` argument shape is unverified** in the landscape
    note. This policy does not inspect arguments, so that does not affect enforcement — but
    a companion `confirm`-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.
direction: ingress
apps:
  - tableau
industries: []
bundles:
  - soc2
experimental: true
schemaVersion: 1.0.0
minimumGatewayVersion: 1.0.0b24
---

```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)
}
```
