Guard DAX Whole-Table Dumps in Power BI
Power BI semantic models front the warehouse: a model imports or DirectQueries lakehouse/warehouse tables — finance, HR, customer PII.
- Direction
- ingress
- Rego package
power_bi.ingress.guard_warehouse_sql_dax- App
- power-bi
- Bundles
- soc2gdpr-ccpa
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:c0b823df5cbea6f2d0716abaf093e193e4475e556d65bf28b9656c297a391ad8
power-biguard-warehouse-sqlingressdaxexfiltrationsoc2gdpr-ccpa
What this policy does
Direction: ingress (tool_pre_invoke)
Default: deny on a bare full-table EVALUATE (fail open when no DAX text is present — nothing to dump), allow otherwise
Package: power_bi.ingress.guard_warehouse_sql_dax
What it does
Power BI semantic models front the warehouse: a model imports or DirectQueries
lakehouse/warehouse tables — finance, HR, customer PII. DAX is read-only, so
the risk on the query path is not corruption but wholesale exfiltration: a bare
EVALUATE 'Customers' returns the entire Customers table in one call.
This ingress policy inspects the DAX expression argument on the query-capable
Power BI tools and denies a bare full-table evaluation — EVALUATE
immediately followed by a table reference with no row-limiting or aggregating
function wrapping it. A query that bounds or aggregates the table — TOPN,
FILTER, SUMMARIZE, SUMMARIZECOLUMNS, SAMPLE, ROW, or a filtered
CALCULATETABLE — passes through, because every one of those constructs
introduces a function call (() after EVALUATE, and the guard's anchored
pattern only fires when the table reference runs unbroken to the end of the
statement.
The check runs at ingress, before the DAX reaches the model, so a blocked full-table dump never executes and no rows are ever returned to the agent.
Fail-open on missing DAX
Unlike a write guard, this policy fails open when a matched tool carries no readable DAX string (missing or empty argument): with no query there is nothing to dump, so the call is allowed. This is the opposite posture from the SQL write-guard sibling (which fails closed on empty SQL) — here an empty argument is inert, not a bypass, because the tool cannot exfiltrate without a query.
Compliance alignment
- SOC 2 CC6.7 — supports restricting the transmission, movement, and removal of information: the most permissive form of programmatic read (an unfiltered whole-table pull) is blocked on the agent MCP path, so an agent cannot move an entire model table off the gateway in one call without a bounded, approved extract.
- GDPR Art. 5(1)(c) — supports data minimisation on the agent channel by forcing DAX reads to bound or aggregate the result rather than returning an entire personal-data table in a single evaluation.
Tool name matching
The policy matches the query-capable Power BI tools by suffix (the gateway
prefixes tool names with the configured MCP server name, which is not
standardized), on lower(input.resource.name):
*executequery— remote official server (ExecuteQuery, PascalCase on the wire; matched lowercased).*execute_dax— communitysulaiman013/powerbi-mcpserver. This suffix also coversdesktop_execute_dax(the Desktop variant), since that name ends withexecute_dax.*dax_query_operations— modeling server (microsoft/powerbi-modeling-mcp) DAX query multiplexer.
Metadata/read tools that do not carry a DAX expression — GetSemanticModelSchema,
GetReportMetadata, ValueSearch, cloud_list_tables, the *_operations
metadata multiplexers — do not end with any of these suffixes and pass through
untouched. (ValueSearch searches raw data values and is better paired with the
egress PII redaction policy; it is out of scope for this DAX-text guard.)
Argument shape
The DAX text is read from input.payload.args across a set of candidate keys —
dax_query (verified for the community server), plus dax, query,
expression, and daxQuery as defensive fallbacks — and the policy inspects the
concatenation of whichever string values are present. Only string values are
considered; a non-string value (list/object) contributes nothing.
The exact DAX argument field name is unverified in the landscape note for the
remote and modeling servers (the preview docs describe "a DAX query expression
(string)" without naming the wire field). If your deployment names the argument
something outside the candidate set, the guard sees no DAX text and — per the
fail-open posture — allows the call. Add the real key to dax_arg_keys in
policy.md once you confirm it with the dump-input debug technique.
Examples
Denied — bare full-table dump
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "powerbi-mcp-ExecuteQuery", "type": "tool" },
"payload": {
"name": "powerbi-mcp-ExecuteQuery",
"args": { "expression": "EVALUATE 'Customers'" }
}
}
}
allow = false with the whole-table-dump reason.
Allowed — row-bounded with TOPN
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "powerbi-mcp-ExecuteQuery", "type": "tool" },
"payload": {
"name": "powerbi-mcp-ExecuteQuery",
"args": { "expression": "EVALUATE TOPN(100, 'Customers')" }
}
}
}
allow = true — the TOPN( call breaks the bare-table pattern.
Allowed — filtered
EVALUATE FILTER('Customers', 'Customers'[Region] = "EMEA") and
EVALUATE SUMMARIZECOLUMNS('Customers'[Country], "n", COUNTROWS('Customers'))
both pass — an aggregating/filtering function follows EVALUATE.
Allowed — no DAX present (fail open)
A matched tool called with no DAX argument (or an empty string) is allowed: there is no query, so nothing can be dumped.
Composition
This guard is single-purpose and deliberately narrow. Pair it with:
- Egress PII redaction on
ExecuteQuery/execute_dax/ValueSearchresults — a filtered query the pattern allows can still return regulated data, and DAX obfuscation (below) can slip a dump past this ingress guard. The egress policy is the backstop that masks email/SSN/PAN patterns in returned rows. This ingress guard is not sufficient on its own. - PF-07 SQL write guard siblings on the warehouse connectors behind the model (Snowflake/BigQuery/Databricks) for the write/DDL surface DAX cannot reach.
- A service-identity guard — the remote server does not enforce row-level
security under service-principal auth, so a shared-credential deployment
widens every caller's scope. Blocking service-identity
ExecuteQuerysessions is a separate, complementary policy.
Known limitations
- Regex over DAX text, not a parser. This is a high-signal guard for the
common bare-dump shape, not a complete DAX parser. It matches
EVALUATEfollowed by a table reference that runs unbroken to the end of the statement. Known evasions, all documented rather than caught:- Variables /
DEFINEblocks.DEFINE VAR t = FILTER('Customers', …)thenEVALUATE t— the trailingEVALUATE tmatches the bare-table pattern and is denied even though it is filtered (false positive); conversely a variable that resolves to a whole table can be dumped through an aggregating-looking wrapper the regex misreads. Variable indirection is the primary evasion. - Comments containing parentheses (unquoted tables only). For an
unquoted table,
EVALUATE Customers /* uses ( */— the(inside the comment breaks the unquoted end-of-line pattern, so the dump is allowed (false negative). Note this evasion does not work for a quoted table name:EVALUATE 'Customers' /* uses ( */is still caught, because the quoted pattern matches the'…'table reference directly afterEVALUATEand ignores any trailing comment. - A comment between
EVALUATEand the table (quoted or unquoted).EVALUATE /* x */ 'Customers'andEVALUATE // x⏎'Customers'are full dumps that are not caught (false negative): the comment token sits where the pattern expects the table reference or an opening(, so neither the bare nor the parenthesised pattern fires. This defeats the quoted pattern too — a leading comment is stronger than the trailing-comment evasion above. Stripping DAX comments before matching is beyond a conservative regex; rely on the egress redaction companion. - Parenthesised bare table — CAUGHT for single wrap, residual for nesting.
EVALUATE ('Customers')andEVALUATE (Customers)(a table wrapped in bare parentheses, still a whole-table dump) are now denied by theparen_*_dump_patternrules. Deeper nesting —EVALUATE (('Customers'))— is not caught, because the inner content is no longer a lone table reference the pattern recognises. Multi-level parenthesisation is a documented residual in the same class as variable indirection. CALCULATETABLEwithout a filter. Only a filteredCALCULATETABLEis an intended allow; an unfilteredEVALUATE CALCULATETABLE('Customers')is effectively a full dump but is not caught, because the(immediately follows a function name (CALCULATETABLE), not a lone table reference. Detecting filter-lessCALCULATETABLEis beyond a conservative regex.
- Variables /
- Unverified argument field name. The remote/modeling DAX argument key is not
verified in the landscape note; a call whose DAX lands under a key outside
dax_arg_keysis allowed (fail open). Confirm the key and add it. - Fail-open by design. Because a query-only tool cannot exfiltrate without a query, an empty/absent/misnamed DAX argument is allowed. Combined with the obfuscation residual above, treat this as a first line of defense and rely on the egress redaction companion for the data actually returned.
- No identity exemption. All callers are subject to the same check. If a
data-team break-glass path needs full exports, gate it with
input.subject.claims.groupsas a separateallow ifbranch; group names in such a branch are placeholders — replace them with your IdP's group name.
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 power_bi.ingress.guard_warehouse_sql_dax
# Deny-by-default: a query-capable Power BI DAX tool call is permitted unless it
# carries a bare full-table EVALUATE. The explicit allow rules below cover the
# passthrough, fail-open (no DAX), and bounded-query cases; anything left over
# (a matched tool + readable DAX + bare-dump pattern) falls to the default deny.
default allow := false
# --- Query-capable DAX tools (matched by suffix) -----------------------------
# The gateway prefixes tool names with the configured MCP server name (not
# standardized), so match on the lowercased suffix for portability:
# - `executequery` — remote official server (ExecuteQuery, PascalCase)
# - `execute_dax` — community sulaiman013 server; also covers
# `desktop_execute_dax` (ends with `execute_dax`)
# - `dax_query_operations` — modeling server DAX query multiplexer
dax_tool_suffixes := [
"executequery",
"execute_dax",
"dax_query_operations",
]
is_dax_tool if {
name := lower(input.resource.name)
some suffix in dax_tool_suffixes
endswith(name, suffix)
}
# --- DAX extraction ----------------------------------------------------------
# The exact wire field name is unverified for the remote/modeling servers; read
# the DAX text from a set of candidate keys and inspect the concatenation of
# whichever STRING values are present. `dax_query` is verified for the community
# server; the rest are defensive fallbacks. A non-string value contributes
# nothing, so it cannot smuggle a query past the string-based pattern.
dax_arg_keys := ["dax_query", "dax", "query", "expression", "daxQuery"]
dax_text := concat(" ", [v |
some key in dax_arg_keys
v := object.get(input.payload.args, key, "")
is_string(v)
v != ""
])
# --- Bare full-table dump detection ------------------------------------------
# `(?i)` case-insensitive (DAX keywords are case-insensitive). `\b` avoids
# matching a longer identifier ending in EVALUATE. Two patterns, because relying
# on a single end-of-TEXT (`$`) anchor was bypassable: a DAX query may contain
# several EVALUATE statements, so appending a second statement that merely
# contains a `(` (e.g. `EVALUATE 'Customers'\nEVALUATE ROW("x",1)`) used to push
# the dump off the `$` anchor and slip through. The two patterns below anchor on
# statement/line boundaries and on the quote-directly-after-EVALUATE shape, so a
# trailing statement, comment, or decoy argument key can no longer hide a dump.
#
# 1. Quoted bare table — a single-quoted table name directly follows EVALUATE.
# No DAX *function* is single-quoted, so `EVALUATE '<name>'` is unambiguously
# a whole-table evaluation. This pattern deliberately does NOT anchor to end
# of text, so it fires regardless of any trailing statement/comment/paren and
# also catches table names that themselves contain `(` (e.g. 'Sales (2)').
# A filtering/aggregating call puts its quote INSIDE parens after a function
# name (`FILTER('Customers',…)`), so the quote does not directly follow
# EVALUATE and this pattern does not fire.
# 2. Unquoted bare table — EVALUATE + an identifier whose run to end-of-LINE
# (or end of text) contains no `(`. Terminating on the line break rather than
# end-of-text means a following statement that contains a `(` no longer hides
# a preceding unquoted dump. TOPN(/FILTER(/SUMMARIZE(/SUMMARIZECOLUMNS(/
# SAMPLE(/ROW(/CALCULATETABLE( all introduce a `(` on the same line and so
# break this pattern and are allowed.
bare_quoted_dump_pattern := `(?i)\bEVALUATE\s+'[^']*'`
bare_unquoted_dump_pattern := `(?i)\bEVALUATE\s+[A-Za-z_][^(\r\n]*(\r?\n|$)`
# 3+4. Parenthesised bare table — `EVALUATE ('Customers')` / `EVALUATE (Customers)`.
# A table reference wrapped in nothing but parentheses is still a whole-table
# dump; the outer `(` used to masquerade as a bounding/aggregating function
# call and slip the dump straight through (patterns 1 and 2 both require the
# table token to follow EVALUATE with no intervening `(`). These two patterns
# fire ONLY when the parentheses contain a lone table reference (a quoted name
# or a single bare identifier) and immediately close — so a real bounding call
# inside the parens (`EVALUATE (FILTER('Customers',…))`, first token after `(`
# is `FILTER` then another `(`, not a closing `)`) does NOT match and still
# passes. Deeper nesting (`EVALUATE (('Customers'))`) is a documented residual.
paren_quoted_dump_pattern := `(?i)\bEVALUATE\s*\(\s*'[^']*'\s*\)`
paren_unquoted_dump_pattern := `(?i)\bEVALUATE\s*\(\s*[A-Za-z_][A-Za-z0-9_]*\s*\)`
is_bare_table_dump if {
regex.match(bare_quoted_dump_pattern, dax_text)
}
is_bare_table_dump if {
regex.match(bare_unquoted_dump_pattern, dax_text)
}
is_bare_table_dump if {
regex.match(paren_quoted_dump_pattern, dax_text)
}
is_bare_table_dump if {
regex.match(paren_unquoted_dump_pattern, dax_text)
}
# --- Allow rules -------------------------------------------------------------
# Non-guarded tools (metadata/read helpers, anything not a DAX query tool) pass.
allow if {
not is_dax_tool
}
# Fail OPEN: a query tool with no readable DAX text cannot dump anything.
allow if {
is_dax_tool
dax_text == ""
}
# A query tool with DAX that is NOT a bare full-table dump (bounded/aggregated).
allow if {
is_dax_tool
dax_text != ""
not is_bare_table_dump
}
# --- Deny reason -------------------------------------------------------------
reasons contains "This DAX query is a bare full-table evaluation (EVALUATE over an entire table with no row-limiting or aggregating function), which returns the whole table in one call and is blocked on the agent MCP path. Add a FILTER predicate or wrap the table in TOPN to bound the result, or request an approved data extract from the data team if a full export is genuinely required. If this was a false positive (for example a filtered query the guard could not parse), contact your data platform team." if {
is_dax_tool
dax_text != ""
is_bare_table_dump
}
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