An agent with credentials to raw tables can read every column, including the ones with names and email addresses, and it will write its own joins. Some of those joins will be wrong. The fix is old and boring: publish views that hold the curated columns and the correct joins, and give the agent access to those views and nothing else. This page builds that, step by step, over Apache Iceberg tables.

The shape of the pattern

In the demo the engine is DuckDB and the views live in a governed schema. In a production lakehouse engine the same shape is a folder or schema of views plus grants that give the agent's service identity SELECT on the views and no privilege on the tables underneath.

Step 1: Keep the raw tables where agents cannot name them

The demo writes two Iceberg tables with PyIceberg: sales.customers, which deliberately includes customer_name and email, and sales.orders. The catalog is a SQLite file and the warehouse is a local folder, so there is nothing to install beyond Python packages.

from pyiceberg.catalog import Catalog, load_catalog

def get_catalog(home=None) -> Catalog:
    """Load (or create) the SQL catalog backed by SQLite and a local warehouse."""
    root = demo_home(home)
    warehouse = root / "warehouse"
    warehouse.mkdir(parents=True, exist_ok=True)
    return load_catalog(
        "demo",
        **{
            "type": "sql",
            "uri": f"sqlite:///{root / 'catalog.db'}",
            "warehouse": warehouse.as_uri(),
        },
    )

The agent never receives catalog credentials. The MCP server holds them, and the only things it exposes are metrics and dimensions. That is the first boundary: the agent cannot ask for a table it has no way to name.

Step 2: Declare the governed view in the semantic model

The view definition belongs next to the metrics that use it, in version control, reviewed like code. The column list is the column policy: anything not selected here cannot be reached by any metric or dimension.

sources:
  orders:
    table: sales.orders
  customers:
    table: sales.customers

governed_views:
  orders_enriched:
    description: One row per order with the customer's segment. No personal data.
    sql: |
      SELECT
        o.order_id,
        o.order_date,
        o.customer_id,
        o.region,
        o.channel,
        o.product_category,
        o.status,
        o.amount,
        o.discount,
        c.segment
      FROM {orders} AS o
      JOIN {customers} AS c ON o.customer_id = c.customer_id
    row_policy_column: region
    time_column: order_date
    cost_source: orders

Three details matter. The join from orders to customers is written once, by someone who knows the keys, so no agent reinvents it. customer_id stays because counting distinct customers is a legitimate metric, while the name and email columns stay behind. And {orders} and {customers} are placeholders, so the view text never hard-codes where raw data lives.

Step 3: Build raw and governed schemas in the engine

At startup the server reads each source table from Iceberg into a private raw schema, then creates each governed view in the governed schema.

RAW_SCHEMA = "raw"
GOVERNED_SCHEMA = "governed"

def build_connection(catalog, model, role) -> duckdb.DuckDBPyConnection:
    """Load sources from Iceberg and return a locked DuckDB connection."""
    con = duckdb.connect(database=":memory:")
    con.execute(f"CREATE SCHEMA {RAW_SCHEMA}")
    con.execute(f"CREATE SCHEMA {GOVERNED_SCHEMA}")

    for key, identifier in model.sources.items():
        try:
            arrow = catalog.load_table(identifier).scan().to_arrow()
        except NoSuchTableError as exc:
            raise RuntimeError(f"Iceberg table {identifier} not found. Run `slh-seed` first.") from exc
        con.register("_incoming", arrow)
        con.execute(f"CREATE TABLE {RAW_SCHEMA}.{key} AS SELECT * FROM _incoming")
        con.unregister("_incoming")

    for view_name in model.views:
        con.execute(governed_view_sql(model, view_name, role))

    # Defense in depth: no file, HTTP, or extension access from here on.
    con.execute("SET enable_external_access = false")
    con.execute("SET lock_configuration = true")
    return con

governed_view_sql fills in the placeholders and, when the role has one, wraps the view in its row filter. The next pattern covers that part.

Step 4: Lock the engine

The last two statements above are easy to skip and worth keeping. enable_external_access = false stops DuckDB from reading files, URLs, or extensions, so a query that somehow got past every other check still could not call read_csv('/etc/passwd'). lock_configuration = true stops anyone from turning it back on. Every query then runs on a cursor of this connection, and cursors share the same locked database.

In a server-based engine the equivalent is a service account for the agent that holds SELECT on the governed views and nothing else: no table privileges, no ability to create objects, no external data sources.

Step 5: Only compile SQL against governed views

The views only help if nothing ever queries around them. In the demo the agent cannot send SQL at all. The compiler turns metric names into SQL, and it only knows one FROM clause:

sql = f"  SELECT {', '.join(select)}\n  FROM governed.{view.name}"

A second, independent check parses the compiled statement with DuckDB's own parser and refuses anything that reads a relation outside the governed views. That check is described in query guardrails.

Step 6: Test the boundary, not just the happy path

These tests run against real Iceberg tables in a temporary directory. They fail if a PII column appears in a governed view, or if the engine can read a file.

def test_governed_view_has_no_pii_columns(analyst):
    cols = [r[0] for r in analyst.con.execute("DESCRIBE governed.orders_enriched").fetchall()]
    assert "email" not in cols and "customer_name" not in cols
    assert {"region", "segment", "amount"} <= set(cols)


def test_external_access_is_off_and_locked(analyst):
    with pytest.raises(duckdb.Error):
        analyst.con.execute("SELECT * FROM read_csv('/etc/hosts')").fetchall()
    with pytest.raises(duckdb.Error):
        analyst.con.execute("SET enable_external_access = true")
    # Cursors (used for every query) share the same locked database.
    with pytest.raises(duckdb.Error):
        analyst.con.cursor().execute("SELECT * FROM read_csv('/etc/hosts')").fetchall()

Done when

Taking it to production

The demo loads Iceberg tables into memory because it has to run anywhere in a few seconds. In production the views sit in the engine that queries Iceberg in place, and the engine enforces the grants. Keep the view definitions in the same repository as the semantic model so a pull request that adds a column to a view is reviewed by someone who knows what the column holds. Iceberg's own view specification lets several engines share a view definition; see Iceberg view specification for how far that support goes today.

Build with the community