---
name: Fence Regulated BigQuery Datasets by Group
tags:
  - bigquery
  - fence-sensitive-scopes
  - ingress
  - rbac
  - soc2
  - hipaa
  - pci-dss
  - gdpr-ccpa
publishedAt: 2026-07-12
description: |
  # bigquery / fence-sensitive-datasets

  **Direction:** ingress (`tool_pre_invoke`)
  **Default:** deny a regulated-dataset reference for callers outside the mapped data-domain group, allow otherwise
  **Package:** `bigquery.ingress.fence_sensitive_datasets`

  ## What it does

  Fences customer-designated **regulated BigQuery data domains** by data-domain IdP
  group, at ingress, before any statement or metadata lookup reaches BigQuery. The
  policy carries one placeholder map, `sensitive_domains`, pairing a **dataset name
  prefix** with the IdP group required to touch that domain:

  - `phi_` → `clinical-data`
  - `finance_` → `finance`
  - `pii_` → `data-privacy`

  It inspects **two surfaces** so that recon and enumeration are fenced before any
  `SELECT` runs — not just the query itself:

  - **Raw SQL** on the query tools (`execute_sql`, `execute_sql_readonly`,
    `query`, `execute-query`). **Every string argument value** on these tools is
    scanned for a table reference whose dataset component begins with a fenced
    prefix (e.g. `phi_labresults.records`, `myproj.finance_ledger.gl`,
    `` `pii_customers.profiles` ``). Scanning all string args — not just the
    documented `sql`/`query` keys — means a community server that names its SQL
    argument something else cannot slip a query past the fence fail-open.
  - **`dataset_id` / `table_id` arguments** on the metadata and read tools
    (`get_dataset_info`, `get_table_info`, `list_table_ids`, `list-tables`,
    `describe-table`). This closes the enumeration path: a caller cannot map or
    describe a regulated dataset's schema before deciding what to `SELECT`.

  If a fenced prefix is referenced on either surface and the caller's IdP `groups`
  claim does **not** include the mapped group, the call is denied. The deny reason
  names the data domain and the exact group required, plus an escalation hint.

  Group membership is read through `object.get(input.subject, "claims", {})` chains
  that **fail closed**: a missing, empty, or malformed `subject`/`claims`/`groups`
  never grants a fenced domain — no group means not exempt. A caller holding the
  *wrong* domain group (e.g. `finance` querying a `phi_` dataset) is likewise denied,
  because each domain requires its own specific group. Calls that reference no fenced
  prefix, and calls to tools outside the two inspected surfaces, pass through
  untouched.

  ## Compliance alignment

  - **HIPAA §164.502(b)/§164.514(d)** — supports the minimum-necessary and
    role-based-limits standard: agent access to a PHI-bearing dataset (`phi_`) is
    gated to its mapped `clinical-data` group on the MCP path, including the
    metadata/enumeration tools so a caller cannot even map PHI schema without the
    entitlement; **§164.308(a)(4)** — supports information access management by
    authorizing sensitive-domain access via IdP group.
  - **PCI DSS 7.2.6** — supports restricting programmatic query access to stored
    cardholder data by role: fence the `finance_` prefix so only the `finance` group
    can query or enumerate it through an agent.
  - **SOC 2 C1.1** — supports identification and protection of confidential
    information by gating agent SQL and metadata calls against designated
    confidential datasets; **P4.1** — supports limiting personal-information use to
    identified purposes by keeping PI-bearing datasets behind a role fence.
  - **GDPR Art. 9** — supports special-category protection by fencing datasets
    holding health (`phi_`) or other Art. 9 data; **CPRA §1798.121** — supports the
    right to limit use of sensitive personal information by fencing the `pii_` domain
    to a minimal group.
  - **SOX (ITGC — access to programs & data)** — supports least-privilege access to
    financial data: fencing the `finance_` domain to the `finance` group confines
    agent reads of financially relevant warehouse data to authorized personnel on the
    MCP path.

  ## Why ingress and least-privilege

  This is the minimum-necessary / least-privilege control for the warehouse's data
  plane: it stops a regulated-dataset reference — read, query, *or* enumeration —
  before it executes, rather than masking a response the query already produced.
  Fencing the metadata tools matters because schema recon (`get_dataset_info`,
  `list_table_ids`) is itself a disclosure and a targeting step; blocking it early
  denies the agent the map it would use to craft an exfiltration `SELECT`. Pair with
  an **egress redaction backstop** (see Composition) for defense in depth, since
  which specific tables hold regulated data is not knowable from the wire.

  ## Tool name matching

  Tools are matched by **suffix** (the gateway prefixes tool names with the
  configured MCP server name, which is not standardized). Two classes are inspected:

  - **SQL query tools** — `execute_sql` and `execute_sql_readonly` (Google
    official remote server + MCP Toolbox `bigquery` toolset, snake_case),
    `query` (ergut/mcp-bigquery-server), `execute-query`
    (LucasHild/mcp-server-bigquery, kebab-case). `execute_sql_readonly` is
    matched explicitly because a read-only SELECT still bulk-reads a regulated
    dataset. **Every string argument value** on these tools is scanned (not just
    `sql`/`query`).
  - **Metadata / read tools** — `get_dataset_info`, `get_table_info`,
    `list_table_ids`, `list-tables`, `describe-table`. Their `dataset_id`/`table_id`
    arguments are scanned.

  Any other tool (including `list_dataset_ids`, which lists dataset names with no
  target argument) is **not** inspected and passes through — see Known
  limitations. Verify the exact tool names your gateway emits with the
  dump-input debug technique before relying on this in production.

  ## Argument shape

  - **SQL query tools** — `sql` / `query` are the documented keys carrying the
    GoogleSQL text, but **all** string argument values are read and scanned, so a
    server that carries the SQL under a different (unverified) key is still
    fenced rather than passing fail-open.
  - `dataset_id` / `table_id` — the dataset/table identifiers (strings) on the
    metadata tools. A fully-qualified `table_id` (`finance_gl.journal`) is matched as
    readily as a bare dataset id (`pii_customers`). The community kebab-case tools
    (`list-tables`, `describe-table`) whose argument key is unverified are also read
    under the aliases `dataset`, `table`, and `table_name`, so a metadata tool this
    policy claims to inspect is fenced regardless of which of these keys it uses.

  Only string argument values are inspected; a non-string value contributes no text.

  ## Examples

  ### Allowed — cleared caller queries a fenced dataset

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "bigquery-mcp-execute_sql", "type": "tool" },
      "subject": { "sub": "google-apps|nurse@example.com", "claims": { "groups": ["clinical-data"] } },
      "payload": {
        "name": "bigquery-mcp-execute_sql",
        "args": { "sql": "SELECT patient_id, visit_date FROM phi_records.encounters WHERE patient_id = 42" }
      }
    }
  }
  ```

  `allow = true`, no reason.

  ### Denied — uncleared caller references a fenced dataset in SQL

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "bigquery-mcp-execute_sql", "type": "tool" },
      "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } },
      "payload": {
        "name": "bigquery-mcp-execute_sql",
        "args": { "sql": "SELECT customer_id, email FROM pii_customers.profiles WHERE id = 5" }
      }
    }
  }
  ```

  `allow = false`, reason names the personal-data (PII) domain and the required
  `data-privacy` group.

  ### Denied — schema recon on a fenced dataset via a metadata tool

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "bigquery-mcp-get_dataset_info", "type": "tool" },
      "subject": { "sub": "google-apps|analyst@example.com", "claims": { "groups": ["finance"] } },
      "payload": {
        "name": "bigquery-mcp-get_dataset_info",
        "args": { "dataset_id": "phi_labresults" }
      }
    }
  }
  ```

  `allow = false` — the caller holds `finance`, not the `clinical-data` group the
  `phi_` domain requires, so even the metadata lookup is fenced.

  ## Composition

  This policy is single-purpose. Curated companions for the BigQuery data plane:

  - **`guard-warehouse-sql` (ingress)** — deny DML/DDL/`GRANT` so a fenced-out caller
    cannot pivot to a destructive write; keeps ordinary agent callers read-only.
  - **`guard-warehouse-export` (ingress)** — deny `EXPORT DATA`/`EXTERNAL_QUERY`
    exfiltration constructs that move regulated data out without looking like a write.
  - **`redact-pii-egress` (egress)** — redact SSN/PAN/email patterns from returned
    rows: the defense-in-depth backstop for regulated tables not yet pinned here, or
    referenced by a dynamic construct this ingress fence cannot see.

  ## Known limitations

  - **Placeholder configuration.** The prefixes (`phi_`, `finance_`, `pii_`) and
    group names (`clinical-data`, `finance`, `data-privacy`) are placeholders —
    replace them with your tenant's real dataset naming scheme and your IdP's
    group-claim values at import time. Which datasets hold regulated data is not
    knowable from the wire, so pinning the naming convention is mandatory for this
    policy to do anything.
  - **Dynamic and indirect references evade the prefix regex.** Detection matches the
    literal fenced prefix at an identifier boundary in the wire text. A dataset
    reference built dynamically (`EXECUTE IMMEDIATE`, string concatenation, a
    parameter/bind) or reached indirectly through a **view defined in another
    dataset** — where the un-prefixed view name is what appears in the SQL — is not
    fenced. This is a fundamental limit of wire-level inspection; pair with
    `guard-warehouse-sql` (to scope dynamic SQL) and keep the egress redaction
    backstop in place. Treat the ingress fence as one layer, not the sole control.
  - **Dataset-listing tool is not inspected.** `list_dataset_ids` lists dataset
    names with no target argument, so it passes through — an uncleared caller can
    still learn that a `phi_`-prefixed dataset *exists* (but not its schema or
    rows). `execute_sql_readonly` **is** now fenced (added to `sql_tool_suffixes`),
    so a read-only `SELECT` from a fenced dataset is denied for an uncleared
    caller just like `execute_sql`.
  - **`INFORMATION_SCHEMA` / region-level enumeration is not fenced.** A query
    such as ``SELECT schema_name FROM `region-us`.INFORMATION_SCHEMA.SCHEMATA``
    enumerates every dataset name in a region without ever writing a fenced
    prefix as a literal table reference, so the boundary-anchored detection does
    not fire and the call passes. This is metadata recon that the wire-level
    prefix match cannot see; pair with a **block-schema-recon** companion policy
    that denies `INFORMATION_SCHEMA`/`__TABLES__` references for callers outside
    analytics/engineering groups, and keep the egress backstop in place.
  - **AI-analytics and catalog-search tools are not inspected.** The MCP Toolbox
    tools `ask_data_insights` (which ships table contents to Google's Conversational
    Analytics API), `forecast`, `analyze_contribution`, and `search_catalog` are
    **not** among the inspected suffixes. The first three take a table reference plus
    a natural-language question (not a `sql`/`dataset_id` argument), so an uncleared
    caller can reference a fenced table through one of them and move its data without
    tripping this fence; their table-reference field name is unverified across
    Toolbox revisions. `search_catalog` is a Dataplex catalog search: it enumerates
    and describes datasets/tables by keyword, so an uncleared caller can use it to
    *discover* a fenced `phi_`/`finance_`/`pii_` dataset's existence and metadata
    (a recon/enumeration path parallel to `list_dataset_ids` and
    `INFORMATION_SCHEMA` above) even though it never carries a literal fenced table
    reference in a scanned argument. Gate the AI tools with a companion
    **gate-ai-analytics** policy and the search tool with a **block-schema-recon**
    companion (deny by group), and keep the egress redaction backstop in place; do
    not rely on this fence to cover the AI-analytics or catalog-search paths.
  - **Non-string / nested argument shapes are not inspected.** Detection reads only
    top-level **string** argument values (SQL tools scan every string arg; metadata
    tools scan the string-valued alias keys). A tool that carries its SQL or target
    identifier inside a **nested object or an array** (e.g. `args.query.sql`, or a
    `statements: [...]` batch) contributes no scanned text, so the call is treated as
    carrying no readable target and **passes through fail-open**. No verified
    BigQuery SQL/metadata server uses such a shape (all pass `sql`/`dataset_id` as
    top-level strings — see the landscape note), so this is a residual for an
    unverified/future community server rather than a live bypass; if you adopt a
    server with a nested argument shape, extend `inspected_text` to walk it, and keep
    the egress backstop in place.
  - **Convention-dependent, boundary-anchored.** Detection keys on a prefix at an
    identifier boundary (`\bphi_`), so a regulated dataset that does not carry the
    pinned prefix is not fenced, and — conversely — a benign column, alias, or
    literal that literally begins with a fenced prefix (e.g. a column named
    `phi_flag`) produces a **conservative (fail-safe) denial** for an un-cleared
    caller. Enforce the dataset naming convention in BigQuery and rely on the egress
    backstop for the residual.
  - **`groups` claim must be an array of strings.** A string-valued or otherwise
    malformed claim fails closed (fenced domains deny). On Auth0 tenants without RBAC
    /permissions configured, no `groups` claim reaches the policy and every fenced
    domain denies until the claim is wired up. If your IdP emits groups under a
    different claim name (e.g. a namespaced custom claim), update `caller_groups` in
    the Rego.
  - **Never uses stripped claims.** Authorization is driven solely by the IdP
    `groups` claim. The ContextForge-internal claims `is_admin`, `user`, and `teams`
    are stripped before reaching the policy and must **never** be used for these
    grants — a rule referencing them would silently never match.

  > **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:
  - bigquery
industries: []
bundles:
  - soc2
  - hipaa
  - pci-dss
  - gdpr-ccpa
experimental: true
schemaVersion: 1.0.0
minimumGatewayVersion: 1.0.0b24
---

```rego
package bigquery.ingress.fence_sensitive_datasets

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

# ---------------------------------------------------------------------------
# Fence configuration — PLACEHOLDERS, replace at import time.
#
# Maps a BigQuery dataset NAME PREFIX -> the IdP group required to touch that
# data domain. Prefixes are matched case-insensitively at an identifier
# boundary (\bPREFIX); groups are compared case-insensitively against
# `subject.claims.groups`. Replace with the tenant's real dataset naming scheme
# and IdP group names.
sensitive_domains := {
	"phi_": "clinical-data", # e.g. phi_records.encounters, phi_labresults
	"finance_": "finance", # e.g. finance_ledger.gl, finance_ar
	"pii_": "data-privacy", # e.g. pii_customers.profiles, pii_events
}

# Human-readable domain label for the deny reason.
domain_labels := {
	"phi_": "protected health information (PHI)",
	"finance_": "financial",
	"pii_": "personal data (PII)",
}

# ---------------------------------------------------------------------------
# Identity — read groups via object.get chains so a missing subject/claims/
# groups fails closed (no group -> no access to a fenced domain).
caller_groups := object.get(object.get(input, "subject", {}), "claims", {})

caller_group_list := object.get(caller_groups, "groups", [])

# True when the caller's groups claim (an array of strings) contains `group`.
# A malformed (non-array) claim makes the iteration fail -> fail closed.
caller_has_group(group) if {
	some g in caller_group_list
	lower(g) == lower(group)
}

# ---------------------------------------------------------------------------
# Tool classification — match by suffix (gateway prefixes the server name).

# Raw-SQL query tools. `execute_sql_readonly` is matched explicitly (its
# `_readonly` suffix is NOT caught by the `execute_sql` suffix) because a
# read-only SELECT still bulk-reads a regulated dataset, which is exactly what
# this fence governs. Every string argument on these tools is scanned, so a
# community server that names its SQL argument something other than `sql`/
# `query` cannot slip a query through fail-open.
sql_tool_suffixes := ["execute_sql_readonly", "execute_sql", "execute-query", "query"]

# Metadata / read tools whose `dataset_id`/`table_id` argument is scanned.
metadata_tool_suffixes := [
	"get_dataset_info",
	"get_table_info",
	"list_table_ids",
	"list-tables",
	"describe-table",
]

tool_name := lower(object.get(input.resource, "name", ""))

is_sql_tool if {
	some suffix in sql_tool_suffixes
	endswith(tool_name, suffix)
}

is_metadata_tool if {
	some suffix in metadata_tool_suffixes
	endswith(tool_name, suffix)
}

# ---------------------------------------------------------------------------
# Argument extraction — object.get everywhere; only string values contribute.
args := object.get(object.get(input, "payload", {}), "args", {})

# SQL text (from a query tool). Scan EVERY string argument value, not only the
# documented `sql`/`query` keys, so a divergent/unverified argument key on a
# community server cannot let an un-fenced query pass fail-open. A stray non-SQL
# string arg that happens to start with a fenced prefix yields a conservative
# (fail-safe) denial — consistent with the boundary-anchored detection below.
inspected_text contains t if {
	is_sql_tool
	some _, v in args
	is_string(v)
	v != ""
	t := v
}

# Metadata identifiers (from a metadata tool). Official servers name these
# `dataset_id`/`table_id`; the community kebab-case tools (LucasHild
# `list-tables`/`describe-table`) may name them `dataset`/`table`/`table_name`.
# Read the union so a metadata tool this policy CLAIMS to inspect cannot slip
# through fail-open just because it used a different (unverified) key name.
metadata_arg_keys := ["dataset_id", "table_id", "dataset", "table", "table_name"]

inspected_text contains t if {
	is_metadata_tool
	some key in metadata_arg_keys
	t := object.get(args, key, "")
	is_string(t)
	t != ""
}

# This policy only inspects the SQL and metadata surfaces above; every other
# tool, and an inspected tool carrying no readable target text, passes through.
is_inspected_call if {
	count(inspected_text) > 0
}

# ---------------------------------------------------------------------------
# Detection.

# True when `text` references an identifier beginning with `prefix`. `\b`
# anchors to an identifier boundary (start of string, whitespace, `.`, `(`,
# backtick, comma), so phi_records and myproj.finance_ledger match but a prefix
# buried mid-identifier (my_pii_col) does not.
domain_in_text(prefix, text) if {
	regex.match(sprintf(`(?i)\b%s`, [prefix]), text)
}

# A fenced domain the caller is NOT cleared for is referenced in the request.
denied_domains contains prefix if {
	some prefix, group in sensitive_domains
	some t in inspected_text
	domain_in_text(prefix, t)
	not caller_has_group(group)
}

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

# Any call this policy does not inspect passes through untouched.
allow if {
	not is_inspected_call
}

# Inspected call that references no fenced domain the caller lacks clearance for.
allow if {
	is_inspected_call
	count(denied_domains) == 0
}

# ---------------------------------------------------------------------------
# Deny reasons — name the data domain, the dataset prefix, and the required
# group, with an escalation hint.
reasons contains msg if {
	some prefix in denied_domains
	group := sensitive_domains[prefix]
	label := domain_labels[prefix]
	msg := sprintf("Access to the %s data domain (BigQuery dataset prefix '%s') requires membership in the '%s' IdP group, which your identity does not carry. Request the '%s' group from your data-governance owner and retry; if you believe this dataset is misclassified, contact your data platform team.", [label, prefix, group, group])
}

reason := joined if {
	count(reasons) > 0
	reason_list := sort([r | some r in reasons])
	joined := concat("; ", reason_list)
}
```
