Your DuckDB, behind a gate.
curral is an open-source SQL gateway for DuckDB and Iceberg lakes. Every query is authenticated, checked against your policy, filtered and masked before it runs, then streamed back over HTTP.
docker pull lucasapassos/curralCurral is Portuguese for corral: where you keep the herd in and decide who gets through the gate.
Send a query through the gate
Simulated in your browser with an example policy.
- Authenticate
- Queue
- Inspect
- Authorize
- Protect
- Execute
Pick a user and a statement, then send it.
DuckDB has no users, no permissions and no network protocol.
So sharing a DuckDB file or an Iceberg lake ends one of two ways: you hand out storage credentials and everyone can read everything, or you build and babysit a custom API for every team that asks.
curral is the layer in between. One binary with DuckDB embedded, speaking HTTP on one side, deciding per statement who may run it, which rows they see and which columns arrive masked.
Six checks between a request and your data
Each stage can stop a request, and every outcome lands in the audit log.
- 1
Authenticate
Basic with bcrypt, service API keys, or JWTs from any OIDC provider. Repeated failures lock the IP or user out.
- 2
Queue
A global concurrency limit plus per-user quotas, so one heavy user cannot starve everyone else.
- 3
Inspect
The statement is prepared, not run. DuckDB’s own planner says which base tables it really reads, through views, CTEs and joins.
- 4
Authorize
An embedded OPA evaluates your Rego policy and returns allow or deny, plus limits and column masks.
- 5
Protect
Each protected table is replaced by a subquery that filters rows and masks columns before your query sees it.
- 6
Execute
A fresh connection and one transaction per request. Results stream as JSON, CSV, NDJSON or Arrow.
Access rules you can review in a pull request
Policies are Rego, evaluated by an embedded Open Policy Agent. No sidecar, no extra service. The policy sees the base tables a statement really reads, taken from DuckDB’s planner rather than a regex, so a view or a CTE cannot smuggle a read past it.
- Row-level security per role or user, driven by config or by an ACL table.
- Column masks computed before your query runs, so a
WHERE ssn = …cannot guess the hidden value. - Dry runs that explain a decision without executing anything.
- Per-role limits for timeout, row count and concurrency.
package curral
import rego.v1
default allow := false
allow if "admin" in input.roles
allow if {
"analyst" in input.roles
input.resolved
input.statement_type == "SELECT"
every t in input.tables { startswith(t, "lake.") }
}
masks := {"lake.analytics.customers": {
"ssn": "last:4", "email": "redact",
}} if not "pii_reader" in input.rolestables:
lake.analytics.customers:
rules:
- roles: [analyst]
where: "region = 'north'"
- users: ["*@partner.example.com"]
where: "region IN (SELECT region FROM ctl.main.acl
WHERE usr = getvariable('curral_user'))"$ curl -u analyst localhost:8080/v1/query \
-d '{"sql": "DELETE FROM orders", "dry_run": true}'
{
"dry_run": true,
"decision": "deny",
"decided_by": "policy",
"statement_type": "DELETE",
"tables": ["lake.analytics.orders"],
"targets": ["lake.analytics.orders"],
"resolved": true,
"policy_sha256": "9f2c…"
}Anything DuckDB can attach
Declare databases in a YAML file and curral attaches them at startup, then locks the engine. Credentials come from the environment and are scrubbed from the process once loaded.
- DuckDB files
- Iceberg REST catalogs
- Cloudflare R2 Data Catalog
- Postgres
- S3 and Parquet
273 ms to 8 ms
Median query time against Cloudflare R2 once the built-in catalog metadata cache is on. Throughput with 8 clients went from 3 to 338 requests per second.
catalog.yaml
extensions: [httpfs, iceberg]
secrets:
- name: r2
type: iceberg
params: { TOKEN: "${R2_TOKEN}" }
databases:
- name: lake
path: ${R2_WAREHOUSE}
schema: analytics
cache_ttl: 30s
options: { TYPE: iceberg, SECRET: r2, ENDPOINT: "${R2_CATALOG_URI}" }
- name: sales
path: /data/sales.duckdbQuery it from anywhere
One statement per request, parameters bound server-side. GET /v1/schema lists only what the caller may query, which makes curral a safe front door for notebooks, BI tools and AI agents.
Streaming one million rows over the full HTTP path
curl -u analyst https://curral.example.com/v1/query \
-d '{"sql": "SELECT * FROM orders WHERE id = $1", "params": [42]}'export CURRAL_URL=https://curral.example.com CURRAL_TOKEN=curral_...
curral query "SELECT count(*) FROM orders" # CSV on stdout
curral query -f arrow -o orders.arrow "SELECT * FROM orders"from curral import Client
c = Client("https://curral.example.com", token="curral_...")
df = c.query("SELECT * FROM orders WHERE amount > $1", [100]).to_pandas()
print(c.dry_run("DELETE FROM orders")) # explains the decision, runs nothing# skills/curral teaches an assistant to discover, validate and query
scripts/curral.sh schema # only what this agent may read
scripts/curral.sh dry-run "SELECT ..." # ask the policy first
scripts/curral.sh query -m 20 "SELECT ..." # then sample, within limitsBuilt to run unattended
- Fail-closed audit log
- One JSON line per request: who, what, which tables, which policy version decided. If the log cannot be written, queries stop.
- TLS and lockouts
- Native TLS 1.2+ or Caddy in front. Failed logins are counted per IP and per user, checked before bcrypt.
- Hot reload
- Send SIGHUP to swap users, keys, policies and row filters. Invalid files are rejected; in-flight queries keep their version.
- Prometheus metrics
- Stage latencies, decisions by role, queue depth, audit health and cache hits, on a separate port.
- Hardened image
- Distroless, non-root, no shell, about 190 MB. Extensions are baked in, so it boots without internet.
- Fuzzed inspection
- A differential fuzzer runs every generated statement against DuckDB and fails if a write escapes the inspection.
Open the gate in two minutes.
Clone the repository and start curral with the example users and policy.
git clone https://github.com/lucasapassos/curral && cd curral
docker run --rm -p 127.0.0.1:8080:8080 \
-v "$PWD/examples:/etc/curral:ro" -e CURRAL_DATA=/var/lib/curral \
lucasapassos/curral serve \
--catalog /etc/curral/catalog.yaml --users /etc/curral/users.yaml \
--policy /etc/curral/policy.rego --policy /etc/curral/roles.json