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');
| Column | Meaning |
|---|---|
kind | native_table · native_view · bridge_read · bridge_readwrite · provider · n6k_remote |
writeable | Rows can be added, changed, removed |
editable | Columns can be added, dropped, renamed |
primary_key | Key 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
n6kd | In-process bridge | |
|---|---|---|
| Between | machines | connections in one process |
| Transport | WebSocket + Arrow | none |
| Permissions | perms.yml, per table | bridge_policy, per verb |
| Auth | bearer token | a one-time setup token |
| Use it when | publishing data to other people | running SQL you did not write |