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/curral

Curral 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.

Who is asking
Statement
  1. Authenticate
  2. Queue
  3. Inspect
  4. Authorize
  5. Protect
  6. 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. 1

      Authenticate

      Basic with bcrypt, service API keys, or JWTs from any OIDC provider. Repeated failures lock the IP or user out.

    2. 2

      Queue

      A global concurrency limit plus per-user quotas, so one heavy user cannot starve everyone else.

    3. 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. 4

      Authorize

      An embedded OPA evaluates your Rego policy and returns allow or deny, plus limits and column masks.

    5. 5

      Protect

      Each protected table is replaced by a subquery that filters rows and masks columns before your query sees it.

    6. 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.
    Read the policy guide
    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.roles

    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.duckdb

    Query 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

    1M rows as Arrow140 ms
    1M rows as CSV570 ms
    1M rows as JSON580 ms
    curl -u analyst https://curral.example.com/v1/query \
      -d '{"sql": "SELECT * FROM orders WHERE id = $1", "params": [42]}'

    Built 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