dtwo Policy Store

Fence Tableau Datasource Scope

Tableau's MCP server is a warehouse proxy: query-datasource runs a VizQL Data Service (VDS) query and returns raw row-level data — PII, PHI, payroll,…

Direction
ingress
Rego package
tableau.ingress.fence_datasource_scope
App
tableau
Bundles
soc2gdpr-ccpa
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:21a1f19606553712a3c9d296f79e427199979cbb3de679a2236a084799e310bc

tableaufence-sensitive-scopesaccess-controldatasourceingresssoc2gdpr-ccpa

What this policy does

Direction: ingress (tool_pre_invoke) Default: deny; explicit allows for non-fenced tools, approved-datasource queries, and cleared image renders Package: tableau.ingress.fence_datasource_scope

What it does

Tableau's MCP server is a warehouse proxy: query-datasource runs a VizQL Data Service (VDS) query and returns raw row-level data — PII, PHI, payroll, financials — from whatever the published datasource connects to, and the image-render tools return the same data drawn as pixels. This policy fences two of those surfaces at ingress, before the call reaches Tableau.

It enforces two independent, group-scoped controls:

  1. query-datasource — per-datasource allowlist. The call is denied unless its datasourceLuid argument is a member of a per-tenant allowlist of approved datasource LUIDs (approved_datasources). datasourceLuid is the clean scope dimension the VDS schema exposes, so this confines the agent channel to datasources an operator has cleared (minimum-necessary / least-privilege). A missing, empty, or non-allowlisted datasourceLuid fails closed and is denied.

  2. get-view-image / get-custom-view-image — analyst-only. These tools return PNG renders of a view. Egress redaction cannot parse pixels, so masking is impossible and deny is the only meaningful control. The policy denies these two tools for any caller whose IdP groups do not include the analyst group (data-analysts, a placeholder). A missing subject, missing claims, or missing/malformed groups claim yields no memberships and fails closed.

Every other Tableau tool — catalog/metadata reads (list-datasources, get-datasource-metadata, get-view, list-workbooks…), the CSV data reads (get-view-data, get-custom-view-data), Pulse, admin-insights, token, and mutation tools — and all non-Tableau tools pass through this policy untouched. Those surfaces are governed by companion policies (see Composition).

Identity gating

The image-render control reads the caller's IdP groups from input.subject.claims.groups through object.get(...) chains, so a missing subject, missing claims, or a missing/malformed groups claim resolves to an empty membership set: no matching group means no access to the image tools. The groups claim must be an array of strings; any other shape yields no memberships. Group names are compared case-insensitively.

Compliance alignment

This policy instantiates sensitive-scope fencing (family PF-23) on Tableau's data-query and image-render paths and supports alignment with:

  • SOC 2 C1.1 — supports identification and protection of confidential information by confining agent queries to a governed set of datasources on the MCP path; P4.1 — supports limiting personal-information use to identified purposes by keeping un-cleared datasources and un-redactable image renders off the agent path.
  • HIPAA §164.502(b)/§164.514(d) — supports the minimum-necessary / role-based-limit standard by scoping agent queries to approved datasources rather than every datasource the connected identity can reach; §164.308(a)(4) — supports information access management: which datasources the agent may query and who may pull image renders are operator decisions enforced at the gateway; §164.522(a) — the allowlist can encode agreed-to restrictions on specific datasources.
  • GDPR Art. 9 — supports special-category protection by keeping datasources holding health, HR, or other Art. 9 data off the agent path until their LUID is allowlisted, and by denying image renders (which cannot be redacted) to non-analysts; CPRA §1798.121 — supports the right to limit use of sensitive personal information by fencing SPI-bearing datasources to a minimal allowlist; Art. 5(1)(b) — supports purpose limitation by keying datasource and image-render access to the caller's approved scope.

Why ingress

Both violations are fully determined by the request alone — the tool name, the datasourceLuid argument, and the caller's claims — so enforcement happens before the call reaches Tableau and restricted rows or renders are never fetched into the model context. This matters most for image renders: once a PNG is returned there is no egress control that can clean it, so the leak must be prevented at ingress. For defense in depth, pair with the egress redaction companion for the CSV data-read surfaces this policy does not fence.

Tool name matching

The official Tableau server uses kebab-case tool names with no vendor prefix (query-datasource, get-view-image); the gateway prefixes them with the configured MCP server name joined by a hyphen (e.g. tableau-query-datasource), and that prefix is not standardized. The policy matches case-insensitively by suffix on the distinctive tails:

  • query-datasource — matches query-datasource, tableau-query-datasource, etc. This tail is distinctive; it does not collide with get-datasource-metadata or list-datasources.
  • get-view-image — the standard-view PNG render.
  • get-custom-view-image — the custom-view PNG render. (get-custom-view-image does not end in get-view-image, so both suffixes are matched explicitly.)

Suffix matching keeps the policy portable across gateway prefixes. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. Tableau Next (the Salesforce-hosted analytics product) uses disjoint snake_case names (analyze_data, get_visualization) and is not covered by this policy — author a separate policy for that server.

Argument shape

query-datasource carries the target datasource as a scalar string datasourceLuid (verified against the VDS query-tool schema). The policy reads it with object.get(args, "datasourceLuid", "") and compares it verbatim against approved_datasources. Tableau LUIDs are canonical lowercase UUIDs; store them in the allowlist exactly as Tableau emits them. A call that omits datasourceLuid, sends an empty value, or carries it under a different key resolves to "", which is not in the allowlist, and is denied (fail closed). The image-render tools take a viewId/customViewId (opaque LUID) plus optional filters; this policy does not inspect their arguments — it denies them wholesale for non-analysts.

Examples

Allowed — query against an approved datasource

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "tableau-query-datasource", "type": "tool" },
    "payload": {
      "name": "tableau-query-datasource",
      "args": { "datasourceLuid": "11111111-1111-1111-1111-111111111111" }  // on the allowlist
    }
  }
}

allow = true, no reason.

Allowed — image render by a data analyst

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "tableau-get-view-image", "type": "tool" },
    "subject": { "sub": "auth0|amy", "claims": { "groups": ["data-analysts"] } },
    "payload": {
      "name": "tableau-get-view-image",
      "args": { "viewId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }
    }
  }
}

allow = true, no reason.

Denied — query against a datasource that is not allowlisted

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "tableau-query-datasource", "type": "tool" },
    "payload": {
      "name": "tableau-query-datasource",
      "args": { "datasourceLuid": "99999999-9999-9999-9999-999999999999" }  // not on the allowlist
    }
  }
}

allow = false, reason = "Tableau datasource 99999999-9999-9999-9999-999999999999 is not on the approved-datasource allowlist ...".

Denied — image render by a non-analyst

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "tableau-get-custom-view-image", "type": "tool" },
    "subject": { "sub": "auth0|eng", "claims": { "groups": ["engineering"] } },
    "payload": {
      "name": "tableau-get-custom-view-image",
      "args": { "customViewId": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" }
    }
  }
}

allow = false, reason = "Tableau image renders (get-view-image / get-custom-view-image) return PNGs that cannot be redacted ...".

Composition

This policy fences the datasource-query and image-render surfaces. Useful companions:

  • An egress PII/PHI/PAN redaction policy on query-datasource, get-view-data, and get-custom-view-data responses — those return row-level data / CSV as text and can be redacted, unlike the PNG renders this policy denies outright. This is the mandatory backstop for the CSV data-read path, which this ingress fence does not cover.
  • A calculation-field guard on query-datasource for non-analyst groups: the VDS calculation field variant accepts an arbitrary Tableau calc expression that can reference any column in the (already-approved) datasource, so fieldCaption-level column fencing is bypassable — treat the presence of calculation as elevated.
  • A token-management deny (get-embed-token, revoke-access-token, reset-consent) and a mutation gate on the delete/update tools and their confirm- twins.
  • A default-deny-unknown-tools policy (PF-28): the hosted Tableau server ships new tools automatically, so the tool inventory drifts forward without any client change.

Known limitations

  • CSV data reads are not fenced here. get-view-data and get-custom-view-data return the same underlying data as the image tools, but as CSV text. A non-analyst denied get-view-image can pull the same view's data through get-view-data. That is intentional: CSV can be egress-redacted, so it is governed by the egress redaction companion rather than an ingress deny. Attach that companion — this policy alone leaves the CSV path open.
  • calculation escape hatch inside an approved datasource. Once a datasource LUID is allowlisted, this policy does not restrict which columns or rows the query reads. The VDS calculation field can reference any column in that datasource, so column-level fencing is out of scope here. Pair with the calculation-field guard companion.
  • Allowlist is literal LUIDs. datasourceLuid is compared verbatim against approved_datasources; a datasource reached by any other LUID is denied (the intended default-deny), which also means the allowlist must contain each cleared datasource's exact canonical LUID. The shipped LUIDs are placeholders — replace them with your tenant's real datasource LUIDs at import time. A caller cannot gain access by re-casing an approved LUID: a case-altered value is a different string, is not in the set, and is denied.
  • Off-schema datasourceLuid still fails closed, but its reason string is cosmetic. The VDS schema types datasourceLuid as a scalar string. A call that sends it as a non-string (number, array, object) or under a different key is not on the allowlist and is denied — the security decision is correct. For a non-string scalar the denial reason interpolates the raw value with %s, which can render a formatting artifact (e.g. %!s(int=123)); the deny is unaffected. Send datasourceLuid as the canonical lowercase-UUID string.
  • Image deny is all-or-nothing. The image-render control is a pure group gate — an analyst may render any view (subject to Tableau's own permissions), and a non-analyst may render none. It does not scope image renders by datasource, because the render tools take an opaque viewId, not a datasourceLuid.
  • Only the official kebab-case server is fenced; snake_case servers pass through. Suffix matching is hyphen-specific (query-datasource, get-view-image), so any Tableau server that exposes the same data surfaces under snake_case names is not matched and passes through un-fenced. This covers the Salesforce-hosted Tableau Next product (analyze_data, get_visualization) and the community FastMCP servers the landscape note flags (e.g. query_datasource, get_view_image, get_view_data — tool names there are unverified). A non-allowlisted datasource query or a non-analyst image render issued against such a server would be allowed. This is by design — the policy is pinned to the official server's verified names and must not guess at unverified underscore names — but it means you must author a separate policy (and/or a PF-28 default-deny-unknown gate) for any non-kebab Tableau server your gateway exposes. Confirm the exact tools/list names with the dump-input debug technique before trusting this fence.
  • Suffix matching anchors on the tail, so a name with extra characters after the distinctive suffix is not matched. The match is endswith(name, "query-datasource") / endswith(name, "get-view-image") / endswith(name, "get-custom-view-image"), which fires only when the distinctive tail is the end of the name. A drifted or versioned variant on the official server whose name carries a further suffix — e.g. tableau-query-datasource-v2, …-query-datasource-async, or …-get-view-image-hd — does not end in the anchored tail, so it is treated as an unfenced tool and passes through: a non-allowlisted datasource query or a non-analyst image render issued under such a name would be allowed. This is the flip side of anchoring on the tail rather than substring-matching (a contains match would false-positive on names like get-query-datasource-metadata), and it is why the policy must be paired with a PF-28 default-deny-unknown-tools gate: the hosted Tableau server ships new/renamed tools automatically, so re-verify tools/list with the dump-input debug technique whenever the server version changes and pin any new query/image variant names into this policy's suffix list.
  • Identity placeholders. The analyst group name (data-analysts) and every entry in approved_datasources are placeholders — replace them with your IdP's group name and your tenant's real datasource LUIDs at import time. The groups claim must be an array of strings; any other shape fails closed.

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.fence_datasource_scope

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

# ---------------------------------------------------------------------------
# CONFIG — PLACEHOLDERS, replace at import time.
#
# Approved datasource LUIDs the agent channel may query via `query-datasource`.
# Tableau LUIDs are canonical lowercase UUIDs; compare verbatim (not lowered).
# Pin the exact LUIDs an operator has cleared for the agent.
approved_datasources := {
    "11111111-1111-1111-1111-111111111111", # e.g. the governed sales-metrics datasource
    "22222222-2222-2222-2222-222222222222", # e.g. the governed ops datasource
}

# IdP group cleared to pull image renders (PNGs that cannot be redacted).
# PLACEHOLDER — remap to your IdP's group name at import time. Compared
# case-insensitively.
image_render_group := "data-analysts"

# ---------------------------------------------------------------------------
# Tool matching. Official server uses kebab-case, no vendor prefix; the gateway
# prefixes with the configured server name joined by a hyphen. Match
# case-insensitively by distinctive suffix so any prefix is covered. Verify
# exact names with the dump-input debug technique. Tableau Next (snake_case) is
# NOT matched by design.
tool_name := lower(object.get(object.get(input, "resource", {}), "name", ""))

# The VDS row-level query surface. `query-datasource` is distinctive and does
# not collide with `get-datasource-metadata` / `list-datasources`.
is_query_datasource_tool if endswith(tool_name, "query-datasource")

# The two PNG image-render surfaces. `get-custom-view-image` does not end in
# `get-view-image`, so both tails are matched explicitly.
is_image_render_tool if endswith(tool_name, "get-view-image")

is_image_render_tool if endswith(tool_name, "get-custom-view-image")

# ---------------------------------------------------------------------------
# Identity — caller's IdP groups, read fail-closed: a missing subject, missing
# claims, or a missing/malformed groups claim yields no memberships, so the
# caller is never treated as cleared by accident.
caller_groups := object.get(
    object.get(object.get(input, "subject", {}), "claims", {}),
    "groups",
    [],
)

member_of(group) if {
    is_array(caller_groups)
    some g in caller_groups
    is_string(g)
    lower(g) == group
}

# ---------------------------------------------------------------------------
# Arguments — object.get everywhere so a missing field fails closed.
args := object.get(object.get(input, "payload", {}), "args", {})

requested_luid := object.get(args, "datasourceLuid", "")

# ---------------------------------------------------------------------------
# Allow rules.

# Any tool this policy does not fence passes through untouched (catalog reads,
# CSV data reads, Pulse, admin-insights, mutations, and all non-Tableau tools).
allow if {
    not is_query_datasource_tool
    not is_image_render_tool
}

# query-datasource: allowed only when the datasourceLuid is on the allowlist.
# A missing/empty LUID resolves to "" which is not in the set -> deny.
allow if {
    is_query_datasource_tool
    approved_datasources[requested_luid]
}

# Image renders: allowed only for callers in the analyst group.
allow if {
    is_image_render_tool
    member_of(image_render_group)
}

# ---------------------------------------------------------------------------
# Deny reasons.

# query-datasource naming a datasource that is not on the allowlist.
reasons contains msg if {
    is_query_datasource_tool
    requested_luid != ""
    not approved_datasources[requested_luid]
    msg := sprintf("Tableau datasource %s is not on the approved-datasource allowlist, so the agent may not query it. Query an approved datasource, or request datasource onboarding through your data-governance owner if you believe this one should be cleared.", [requested_luid])
}

# query-datasource with no datasourceLuid at all — fail closed.
reasons contains msg if {
    is_query_datasource_tool
    requested_luid == ""
    msg := "This Tableau query supplied no datasourceLuid, so it cannot be matched against the approved-datasource allowlist. Re-issue the call naming an approved datasource, and request datasource onboarding through your data-governance owner if the one you need is not yet approved."
}

# Image render by a caller outside the analyst group.
reasons contains msg if {
    is_image_render_tool
    not member_of(image_render_group)
    msg := "Tableau image renders (get-view-image / get-custom-view-image) return PNGs that cannot be redacted, so they are restricted to the data-analyst group. Use a CSV data read (get-view-data) or contact your data-governance owner if your role requires image exports."
}

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)