on GitHub

In-process

Sometimes the thing you need to protect is not across a network. You have one process, two DuckDB connections, and one of them is running SQL you would rather not fully trust — a query a user typed, a notebook cell, a plugin.

virtual_catalog_bridge is a separate extension for exactly that. It exposes selected tables from a source database to a target database in the same process, with per-table, per-verb grants, and no protocol in between.

The handshake

Plain SQL. The source registers a bridge and receives a one-time token, grants one policy per table and verb, and the target redeems the token in an ATTACH:

-- 1. SOURCE: register a bridge id against a source catalog, get a token
SELECT bridge_register_source('b1', 'source_catalog');
-- → '<token>'

-- 2. SOURCE: key overrides first (optional), then the grants
SELECT bridge_primary_key('b1', 'main.users', ['id']);
SELECT bridge_policy('b1', 'main.users', 'select', 'true');
SELECT bridge_policy('b1', 'main.users', 'insert', 'true', 'true');
SELECT bridge_policy('b1', 'main.users', 'update', 'true');
SELECT bridge_policy('b1', 'main.users', 'delete', 'true');
SELECT bridge_policy('b1', 'main.logs',  'select', 'true');

-- 3. TARGET: attach the bridge as a catalog
ATTACH '' AS my_bridge (TYPE virtual_catalog_bridge, ID 'b1', TOKEN '<token>');

A policy is (table, verb, using, check): using is the predicate on rows the verb may reach, check the predicate on rows it may write. 'true' grants the whole table; 'tenant_id = 42' restricts it.

The token carries a nonce unique to the loaded extension binary, so mismatched versions are rejected, and it expires after 30 seconds if unused. Permissions are declared by the source and cannot be changed by the target — the trusted side sets the rules, and the untrusted side cannot widen them.

From Python the handshake collapses into one call. permissions is shorthand for unrestricted grants; policies spells them out:

from n6k_server.bridge import Policy, bridge

bridge(source, target, "my_bridge", source_catalog="memory",
       permissions={"main.users": "readwrite", "main.logs": "read"})

target.sql("SELECT * FROM my_bridge.main.users")
bridge(source, target, "my_bridge", source_catalog="memory", policies=[
    Policy("main.users", "select", "tenant_id = 42"),
    Policy("main.users", "insert", "true", check="tenant_id = 42"),
])

Any binding that can execute SQL can drive it directly — Go, Node, Rust, Java, C++. There are no out-of-band calls.

What the target sees

Tables it was granted, and nothing else. An unlisted table raises a CatalogException — it is not hidden-but-reachable, it is absent.

Reads get the same pushdown as the network path: only requested columns are fetched, and =, !=, <, <=, >, >=, IN, IS NULL and IS NOT NULL are pushed into the query run against the source.

Writes go through INSERT, UPDATE, and DELETE, each only where that verb is granted. Inserts use DuckDB's Appender on the source; updates and deletes collect rowids during the scan and apply them at finalize. Write permission is validated at plan time and re-checked at finalize.

UPDATE and DELETE need a primary key, discovered from duckdb_constraints() or information_schema, or supplied with bridge_primary_key. An update or delete grant on a table with no usable key is refused at setup — the same rule, and the same timing, as the server's boot check.

Introspection

A client rendering a UI usually needs to know what it is allowed to do before it offers a button. One call answers it, and works on a bridge catalog, a plain DuckDB catalog, and a remote TYPE n6k catalog alike:

SELECT * FROM n6k_table_permissions('app');
ColumnMeaning
kindnative_table · native_view · bridge_read · bridge_readwrite · provider · n6k_remote
writeableRows can be added, changed, removed
editableColumns can be added, dropped, renamed
primary_keyKey columns in key order

For a bridge catalog this is an in-memory metadata lookup — no query against the source. n6k_table_describe returns the same capability bits plus columns, defaults, and enum domains for a single table, so a client can render a table editor from one call instead of stitching together DESCRIBE, duckdb_constraints(), and enum_range().

Teardown

DETACH my_bridge;

One bridge is one attached catalog, so detaching it on the target tears the bridge down. From Python, unbridge(target, "my_bridge") does the same.

Transaction semantics

Worth reading before you rely on it:

  • Stateless — commit and rollback on the target are no-ops
  • No snapshot isolation — the target always sees the source's latest committed data
  • Inserts commit on the source independently of the target's transaction

CREATE TABLE and RETURNING are not supported through a bridge entry.

Which one to reach for

n6kdIn-process bridge
Betweenmachinesconnections in one process
TransportWebSocket + Arrownone
Permissionsperms.yml, per tablebridge_policy, per verb
Authbearer tokena one-time setup token
Use it whenpublishing data to other peoplerunning SQL you did not write