--- name: Fence Tableau Datasource Scope tags: - tableau - fence-sensitive-scopes - access-control - datasource - ingress - soc2 - gdpr-ccpa publishedAt: 2026-07-12 description: | # tableau / fence-datasource-scope **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 ```jsonc { "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 ```jsonc { "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 ```jsonc { "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 ```jsonc { "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. direction: ingress apps: - tableau industries: [] bundles: - soc2 - gdpr-ccpa experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```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) } ```