Guard Calculation Expressions in Tableau VDS Queries
Inspects the structured VizQL Data Service (VDS) query carried by Tableau's query-datasource tool and denies the call for callers outside the data-analysts…
- Direction
- ingress
- Rego package
tableau.ingress.guard_query_calculation- App
- tableau
- Bundle
- soc2
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:54719300b40c58e8362b0f58a435d3ffaae08a8f81a5af8ca552ea63750f5a1e
tableauguard-warehouse-sqlingresscalculationvizqlsoc2
What this policy does
Direction: ingress (tool_pre_invoke)
Default: deny when a non-analyst query carries a calculation expression, allow otherwise
Package: tableau.ingress.guard_query_calculation
What it does
Inspects the structured VizQL Data Service (VDS) query carried by Tableau's
query-datasource tool and denies the call for callers outside the
data-analysts group whenever any entry in query.fields[] or
query.filters[] carries a calculation.
The VDS query is normally highly policable: fields are named by fieldCaption,
which gives column-level matching, and datasourceLuid gives a clean scope
dimension (see the companion fence-datasource-scope LUID allowlist). The
calculation field variant is the exception — it accepts an arbitrary
Tableau calc expression that can reference any column in the datasource,
regardless of which fields the rest of the query names. That defeats
column-level (fieldCaption) allowlisting and any assumption about which
columns are exposed. It is Tableau's analogue of a raw-SQL / DAX surface, so
the mere presence of a calculation is treated as elevated and confined to
the data-analysts group.
Two properties matter for correctness:
- Fail safe on a missing or empty query. Both arrays are read through
object.getchains with{}/[]defaults, so a call with noqueryobject, an emptyquery, or emptyfields/filtersarrays carries no calculation,calc_presentis false, and the call is allowed. Absence of a calculation never denies. - Calculations hide in nested field slots too. In the VDS schema a
calculationcan appear directly on afields[]entry, under a filter'sfieldsub-object ({ "field": { "calculation": … }, "filterType": … }), and under a TOP-N filter'sfieldToMeasuresub-object —fieldandfieldToMeasureare the same FilterField union, so either accepts an arbitrary calc. Rather than enumerate positions (which would leave a bypass open the moment Tableau adds another nested field slot), the policywalks the wholequeryobject and flags acalculationkey at any depth, so a calc smuggled into a filter, afieldToMeasure, or any future nesting is caught.
Callers whose IdP-issued groups claim includes data-analysts are exempt and
may use calculations freely. The exemption is read through an object.get
chain that fails closed — a caller with no claims, or no groups claim, is
treated as having no groups and is therefore subject to the deny.
This runs at ingress, before the query reaches the VDS engine, so an arbitrary-expression query from a non-analyst never executes and never returns row-level data.
Compliance alignment
- SOC 2 CC6.3 — supports role-based, least-privilege access on the agent
channel: the arbitrary-expression (
calculation) escape hatch that would let any caller read any column of a published datasource is confined to thedata-analystsgroup, while ordinary callers are held to the declaredfieldCaptioncolumns a companion allowlist can police. CC6.1 — supports logical access security over the datasource read path by keeping the raw-expression surface off the default agent path. - GDPR Art. 5(1)(c) — supports data minimisation on the agent channel:
a
calculationcan pull or derive any personal-data column irrespective of the columns the query otherwise names, so blocking it for non-analysts keeps ordinary agent callers to the declared, minimal set of fields.
Tool name matching
The policy matches the VDS query tool by suffix (the gateway prefixes tool names with the configured MCP server name, which is not standardized):
*-query-datasource— the officialtableau/tableau-mcpVizQL Data Service query tool (kebab-case, no vendor prefix). The landscape note flags the generic single-word suffixes (list-users,search-content) as collision risks, so this policy anchors on the distinctive-query-datasourcesuffix.
Every other Tableau tool (get-datasource-metadata, list-datasources,
get-view-data, the Pulse and admin-insights readers, the mutation tools,
etc.) does not end with -query-datasource and passes through untouched — this
policy is single-purpose. Verify the exact tool name your gateway sends with
the dump-input debug technique before relying on this in production.
Argument shape
The query is read from input.payload.args.query (the verified VDS argument
key for query-datasource; datasourceLuid and limit are siblings this
policy does not inspect). Within it, query.fields[] entries have the shape
{fieldCaption, function?, calculation?, sortDirection?, …} and query.filters[]
entries carry a field sub-object (and, for the TOP-N variant, a
fieldToMeasure sub-object) plus filter-variant keys. The policy treats the
query as elevated when a calculation key with a non-null value appears
anywhere in the query object at any depth — a full recursive walk, not a
fixed set of positions — so it does not matter which field slot the calc rides
in. A non-object array element (e.g. a bare string in fields[]) carries no
calculation key and does not deny.
Examples
Allowed — fieldCaption-only query, non-analyst caller
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "tableau-mcp-query-datasource", "type": "tool" },
"subject": { "claims": { "groups": ["marketing"] } },
"payload": {
"name": "tableau-mcp-query-datasource",
"args": {
"datasourceLuid": "abc-123",
"query": {
"fields": [{ "fieldCaption": "Region" }, { "fieldCaption": "Sales" }],
"filters": [{ "field": { "fieldCaption": "Region" }, "filterType": "SET", "values": ["West"] }]
}
}
}
}
}
allow = true — no calculation anywhere in the query.
Allowed — missing query object fails safe
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "tableau-mcp-query-datasource", "type": "tool" },
"subject": { "claims": { "groups": ["marketing"] } },
"payload": {
"name": "tableau-mcp-query-datasource",
"args": { "datasourceLuid": "abc-123" }
}
}
}
allow = true — no query means no calculation to detect.
Denied — calculation in fields[], non-analyst caller
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "tableau-mcp-query-datasource", "type": "tool" },
"subject": { "claims": { "groups": ["marketing"] } },
"payload": {
"name": "tableau-mcp-query-datasource",
"args": {
"datasourceLuid": "abc-123",
"query": {
"fields": [
{ "fieldCaption": "Region" },
{ "calculation": "SUM([Salary]) / SUM([Headcount])", "fieldAlias": "avg_salary" }
]
}
}
}
}
}
allow = false with the calculation reason.
Allowed — same calculation query by a data-analysts caller
The identical fields-with-calculation call succeeds when
input.subject.claims.groups contains data-analysts.
Composition
This policy blocks the arbitrary-expression surface of VDS queries. Useful companions:
- PF-23
fence-datasource-scope— denyquery-datasourceunlessdatasourceLuidis in the caller group's approved list. This policy and that one are complementary: the LUID allowlist controls which datasource, this controls whether arbitrary expressions may run against it. - PF-02 egress PII/PAN redaction on
query-datasource/get-view-dataresults, since even a permittedfieldCaptionquery can return regulated row-level data. - Admin-insights lockdown and token-management deny for the other sensitive Tableau surfaces this policy does not touch.
Known limitations
- Key match is case-sensitive (and exact). The VDS API uses the lowercase
key
calculation; the policy matches that exact key (JSON keys are case-sensitive). A hand-crafted payload using a differently-cased key (Calculation) or a non-ASCII look-alike/homoglyph key would not be a valid VDS query — the server recognizes only the lowercasecalculationfield, so a field entry keyed otherwise carries no validfieldCaption/calculationand the server rejects it before it executes. The guard would not flag such a payload (fail-open for the guard, not a data leak of a query the server would run) — rely on the server's schema validation as the backstop. See the "mis-cased Calculation key" test case, which pins this behavior. - Query must be a JSON object, not a stringified blob. The policy
walksinput.payload.args.queryas a structured object. If a client passed the query as stringified JSON ("query": "{\"fields\":[{\"calculation\": …}]}"),walksees an opaque scalar with nocalculationkey,calc_presentis false, and the call is allowed unguarded. The official VDSquery-datasourceschema declaresqueryas an object (zod.object), so the Tableau server rejects a string-typed query before it executes — rely on that server-side schema validation as the backstop, exactly as with the case-sensitive-key limitation above (fail-open for the guard, not a data leak of a payload the server would run). See the "allowed — stringified query" test case. - Presence, not semantics. The policy denies on the presence of a
calculation, not on what the expression does. A trivial constant calculation
(
"1") is denied for non-analysts just like a cross-column one — this is intentional fail-safe elevation, since the policy cannot safely parse arbitrary Tableau calc syntax. Analysts are the intended escape valve. - Single tool. Only
-query-datasourceis guarded.get-view-data/get-custom-view-datareturn a view's underlying data as CSV keyed on an opaqueviewIdwith no expression surface to inspect at ingress — govern those with datasource/view scoping and egress redaction instead. Tableau Next'sanalyze_data(a disjoint Salesforce-hosted server) is not covered by this policy. - No batch surface. The official Tableau server exposes no raw-API
passthrough or batch tool, so there is no composite endpoint that could carry
a hidden
query-datasourcecall past this suffix match. - Suffix match assumes a server prefix. The guard fires only when the tool
name ends with
-query-datasource(with the leading hyphen). This relies on the gateway exposing the tool as<server-name>-query-datasource. If a deployment somehow surfaced the bare namequery-datasourcewith no prefix, the suffix would not match and the query would pass unguarded (fail-open for the guarded tool, not a data leak of a blocked payload). This is the portability trade-off the whole suffix-match family accepts; confirm the exact tool name your gateway sends with the dump-input debug technique before relying on this policy. See the "allowed — bare tool name" test case, which documents this behavior. - Group names are placeholders — replace
data-analystswith your IdP's group name at import time. The exemption readsinput.subject.claims.groups; on Auth0 tenants without RBAC/permissions configured, nogroupsclaim reaches the policy and the exemption never fires (fail-closed — every caller is barred from calculations until the claim is wired up).
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.guard_query_calculation
# Deny-by-default: a `query-datasource` call from a non-analyst is permitted only
# when its VDS query carries no calculation expression. Every other tool, and
# every calculation-free query, is allowed.
default allow := false
# --- Guarded tool ------------------------------------------------------------
# Match the VizQL Data Service query tool by its distinctive suffix. The gateway
# prefixes tool names with the configured MCP server name (not standardized), so
# a suffix match keeps the policy portable. The landscape note warns that generic
# single-word suffixes collide across servers, so we anchor on `-query-datasource`.
# resource.name is read through an object.get chain so a missing resource object
# never makes the rule error — it just does not match.
tool_name := lower(object.get(object.get(input, "resource", {}), "name", ""))
is_query_tool if {
endswith(tool_name, "-query-datasource")
}
# --- Calculation detection ---------------------------------------------------
# Read the structured VDS query with fail-safe defaults: a missing
# `payload`/`args`/`query` yields an empty object, so `calc_present` never fires
# and the call is allowed (absence of a calculation never denies). Every hop uses
# object.get so a missing intermediate object cannot make a rule error out.
args := object.get(object.get(input, "payload", {}), "args", {})
query := object.get(args, "query", {})
# A `calculation` is Tableau's arbitrary-expression escape hatch, and the VDS
# schema admits it in several positions: directly on a `fields[]` entry
# (`{calculation: …}`), under a filter's `field` sub-object, and under a TOP-N
# filter's `fieldToMeasure` sub-object — both `field` and `fieldToMeasure` are the
# same FilterField union that accepts `{calculation: …}`. Enumerating positions
# invites a whack-a-mole bypass every time Tableau adds a nested field slot, so
# instead we `walk` the entire query object and treat the query as elevated when a
# `calculation` key with a non-null value appears anywhere at any depth. walk keys
# on the structural key *name*, so a column literally *named* "calculation"
# (a value carried under a `fieldCaption` key) is not matched — only a real
# `calculation:` key is. `count(path) > 0` skips the root node.
calc_present if {
walk(query, [path, value])
count(path) > 0
path[count(path) - 1] == "calculation"
value != null
}
# --- Identity exemption ------------------------------------------------------
# Callers in the `data-analysts` IdP group may use arbitrary calculations. The
# object.get chain fails closed — a missing `subject`, missing `claims`, or
# missing `groups` claim yields an empty list, so an unauthenticated/unclaimed
# caller is never exempt. The is_array guard means a `groups` claim that is a
# bare string (or any non-array shape) yields no memberships and also fails
# closed. Group name compared case-insensitively.
caller_groups := object.get(
object.get(object.get(input, "subject", {}), "claims", {}),
"groups",
[],
)
is_analyst if {
is_array(caller_groups)
some g in caller_groups
is_string(g)
lower(g) == "data-analysts"
}
# --- Allow rules -------------------------------------------------------------
# Any tool that is not the VDS query tool passes through.
allow if {
not is_query_tool
}
# Data analysts may run calculation queries.
allow if {
is_query_tool
is_analyst
}
# Ordinary callers may run the query only when it carries no calculation.
allow if {
is_query_tool
not is_analyst
not calc_present
}
# --- Deny reason -------------------------------------------------------------
reasons contains "This Tableau VizQL Data Service query includes a calculation field, which accepts an arbitrary Tableau calc expression that can reference any column in the datasource and bypasses column-level (fieldCaption) allowlisting. Arbitrary calculations are restricted to the data-analysts group on the agent MCP path. Re-issue the query using only fieldCaption fields and standard filters, or ask a member of the data-analysts group to run it. If you need calculation access, contact your data platform team." if {
is_query_tool
not is_analyst
calc_present
}
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