Serving clients directly
How one node serves clients that connect for themselves: the two doors, the sessions behind them, the operator's control surface, graceful shutdown, and the invariants a served deployment must hold. The per-client connection recipes live in clients/ and are linked, not repeated; the token model is authentication.md; the one-page shape of a node is deployment.md.
Overview
There are two ways a principal reaches the ACL:
- The gateway deployment (spec 001). A trusted component owns the DuckDB connection and prepends
ACL ROLE "r"orACL TOKEN '<jwt>'to every statement. The node keeps no state per client; only the gateway connects to DuckDB. - The doors (specs 040 onward). A client with an ordinary driver connects to the node itself. It
cannot prefix its own statements, so the node turns its token into a session once, and a door
puts
ACL SESSION '<handle>'in front of every statement that connection sends afterwards.
Both doors stand on the same three-function contract (spec 040):
acl_session_open(token) -- VARCHAR handle (128 random bits, hex), or NULL if the token does not verify
acl_session_sql(handle, sql) -- 'ACL SESSION ''<handle>'' ' || sql for a live session, NULL otherwise
acl_session_close(handle) -- ends it; idempotent
A door's whole job is "call acl_session_sql, run what it returns, refuse if it is NULL". The door
decides nothing about policy: rights are resolved per statement in the rewriter, exactly as for a
gateway-prefixed statement, and only identity is cached in the handle. The handle is minted from the
OS CSPRNG, never derived from the token, never handed to a client (a Flight client sees a cookie, a
quack client its connection id), and useless outside this instance.
Two doors ship in the extension (built by default on every non-WASM target):
| door | protocol | clients | build switch |
|---|---|---|---|
Flight SQL (acl_flight_serve) | Arrow Flight SQL over gRPC, TLS native | ADBC, JDBC/DBeaver, Power BI/Fabric | ACL_NO_FLIGHT=1 leaves it out; the function is then absent |
quack (acl_quack_serve) | DuckDB's own client/server protocol, quack's server compiled into acl | a duckdb process with ATTACH ... (TYPE quack) | ACL_NO_QUACK_EMBED=1 leaves it out; the function then refuses by name |
A build without the Flight door has no acl_flight_serve at all; the e2e scripts test for it with
SELECT count(*) FROM duckdb_functions() WHERE function_name='acl_flight_serve'.
Preconditions to open any door
Both acl_flight_serve and acl_quack_serve run the same check (RefuseUnlessServable) before a
socket is touched, so the refusal names the thing to fix. In order:
- A policy source is configured -
acl_use_db(...)or the function driver. acl_allow_anonymous_adminis off.- The parser override is STRICT (
allow_parser_override_extension; the extension sets it on load unless a value was set before). - Cleartext binds loopback only. Without TLS material the door may bind
localhost,127.0.0.1,::1or[::1]and nothing else. A certificate lifts this rule; so does the quack door's explicit'plain'mode (a TLS-terminating proxy upstream).
The four refusals, <fn> being acl_flight_serve or acl_quack_serve:
<fn>: no policy source is configured - a served instance resolves every statement against one, so `acl_use_db` (or the function driver) comes first
<fn>: `acl_allow_anonymous_admin` is on, so a served client could administer the ACL with a bare `ACL ADMIN` - turn it off before opening the door
<fn>: the parser override is "<value>", not STRICT - a served statement that failed to parse as ACL would fall through to plain SQL
<fn>: without a TLS certificate the door serves in the clear, so it binds only localhost - pass a cert and key to serve a non-local address, or put a TLS-terminating proxy in front
The first three stand regardless of TLS: TLS secures the wire, it does not make an unsafe instance
safe. A typical bootstrap therefore ends its admin block with
SET GLOBAL acl_allow_anonymous_admin=false; and only then serves
(test/e2e/flight/bootstrap.sql).
The Flight SQL door
SELECT acl_flight_serve('grpc://localhost:32700'); -- cleartext, loopback only
SELECT acl_flight_serve('grpc://0.0.0.0:32700', '/etc/acl/cert.pem', '/etc/acl/key.pem'); -- TLS, any address
SELECT acl_flight_serve('grpc://0.0.0.0:32700', read_text('cert.pem'), read_text('key.pem')); -- inline PEM
SELECT acl_flight_stop('grpc://localhost:32700');
acl_flight_serve(uri)returns the uri it serves.acl_flight_serve(uri, cert, key)servesgrpc+tlswhatever scheme the uri was written in - the certificate is the intent. Each ofcertandkeyis either inline PEM (recognised by the-----BEGINarmor) or a path/URI read through duckdb's own filesystem (read_text, so an object-store URI rides httpfs). Nothing is written to disk. Both arguments are required together:acl_flight_serve: TLS needs both a certificate and a key - acl_flight_serve(uri, cert, key). A location that yields nothing isacl_flight_serve: the certificate location "<loc>" holds no single document; a file that is not PEM isacl_flight_serve: the certificate at "<loc>" is not PEM (no -----BEGIN marker) - pass a PEM file/URI or inline PEM text. A bracketed IPv6 literal (grpc://[::1]:32700) parses correctly.- One door per uri per process:
acl_flight_serve: a door is already listening on <uri>. A door left behind by a database instance that closed without stopping it is reclaimed by the next serve on the same uri. acl_flight_stop(uri)shuts the listener down synchronously, drops every held session connection and closes every session of the instance, and returns<uri> (N session(s) closed). It refuses a uri nobody serves (acl_flight_stop: no door of ours is listening on <uri>) and a door another instance opened in the same process (acl_flight_stop: the door on <uri> belongs to another database instance).- mTLS is not offered; the token is the client's identity, the certificate is the wire's.
What the door serves, every RPC authenticated per call from the authorization: Bearer <jwt> header:
- Statements as single-use reservations (spec 047). A query is parsed, rewritten and bound once at
GetFlightInfo; the ticket is an opaque id, redeemable atDoGetonly by the principal fingerprint that made it - a stolen ticket earns nothing. Prepared statements carry the same owner rule, and the client's$1/?are the only parameters in the composed statement (the rewriter adds none), so parameter batches bind one-to-one. Text DML ridesDoPut(CommandStatementUpdate)(JDBCexecuteUpdate);executemanyruns as one batch, rolled back whole on a mid-batch refusal. - Where a statement runs (spec 111), as Flight SQL defines it: "GetFlightInfo: execute the query".
- Rows. A statement that returns rows (SELECT,
... RETURNING) is planned atGetFlightInfoand streamed lazily atDoGet. Nothing is held for a client that never pulls.- A
DML ... RETURNING, and a write inside a CTE (WITH w AS (INSERT … RETURNING …) SELECT …), is one of these. It runs atDoGet, when its rows are fetched: a client that writes in this form reads its rows, and one that never fetches never writes. - A parameter that decides a result column of such a write is given its type
(
SELECT id, ?::VARCHAR AS tag FROM w). Untyped, its column has no type before execution, and the door does not execute a write to learn one (spec 112) - a driver that needs the schema up front (ADBC) refuses the statement. A read with an untyped parameter is unaffected.
- A
- A count. A statement that answers only a count (INSERT / UPDATE / DELETE / MERGE without
RETURNING, DDL) runs at
GetFlightInfo. Its count waits for theDoGet, and a refusal is the execute call's own error.- DBAPI's
cursor.execute("INSERT ...")sends DML down the query path and never fetches. The ADBC driver cancels the unreadDoGet, which used to lose the write silently; it no longer does. - A schema without execution is
GetSchema, which runs nothing.
- DBAPI's
- DoPut. It is the main path for DML and DDL: one RPC, nothing left behind on the node.
- A prepared DML or DDL is announced with an empty dataset schema, which JDBC reads as an update
and executes through
DoPut.- JDBC's
executeQuery("INSERT …")therefore writes the row and then throwsStatement did not return a result set, as JDBC defines for a statement that is not a query. The exception does not mean nothing was written.
- JDBC's
- Flight SQL's
is_updateflag (Arrow ≥ the release carrying apache/arrow#49498) will be set too, once the Arrow pin carries it.
- A prepared DML or DDL is announced with an empty dataset schema, which JDBC reads as an update
and executes through
- Rows. A statement that returns rows (SELECT,
- The catalog RPCs (spec 046):
GetCatalogs,GetDbSchemas,GetTables(withinclude_schema),GetTableTypes,GetPrimaryKeys(declared virtual keys, spec 048),GetImportedKeys/GetExportedKeys/GetCrossReference(declared references, spec 022). Every one is aSELECTover the principal's owninformation_schema, run under the same prefix as a statement - there is no second path from the door to the physical catalog. A hidden column is absent from the promised schema; a masked column carries the mask's type.GetSqlInfoanswers build-time constants only (duckdb-acl, the version, transactions supported, bulk ingestion supported). - Bulk ingest (specs 049/051):
DoPut(CommandStatementIngest)- ADBCadbc_ingest. The stream becomes a server-composedINSERT <named columns> SELECT ... FROM arrow_scan(...)under anACL INGESTprefix only the door writes, so capabilities, the grant's predicate and injected columns apply row by row; a refusal anywhere fails the whole statement and nothing lands. Modes:append(into an existing writable virtual table),create(CREATE TABLE ... AS, needscreateon a granted schema with a physical home),replace(CREATE OR REPLACE, priced atcreate+drop);create_appendis refused as ambiguous.acl_max_ingest_rowscaps the rows one ingest may stream, enforced while reading. Atransaction_idon ingest is refused, and so is an ingest inside a client's open transaction (the refusal saysinside an open transaction): the ingest owns its own transaction so the row-count cross-check decides before commit. - Results stream, and a client that stops is heard (spec 070).
DoGetexecutes the statement lazily and converts one duckdb chunk into one Arrow batch as the client pulls it - nothing beyond the chunk in flight is held, whatever the result's size, and the first batch of a 200M-row scan is on the wire in tens of milliseconds. The stream's end interrupts the query the way duckdb's ownLIMITstops a pipeline: consumed (the last chunk), cancelled (the client closed the reader, cancelled the call, applied its own LIMIT or died - gRPC tears the call down and the door interrupts what still runs), superseded (the session's next statement found the stream unpulled foracl_flight_stream_idleseconds - default 30 - and ended it before running; a driver that consumes sequentially never sees this, a statement arriving while the stream is being pulled waits for it; a session the operator kills, or one that expires, takes its stream with it at the next pull -acl: the session ended- while a pull itself counts as the session's activity), capped (acl_max_result_rows, default 0 = unlimited: exactly N rows go out, then the stream ends inacl: the result exceeds acl_max_result_rows (N)- the mirror ofacl_max_ingest_rows) or failed (a source error mid-stream reaches the client as an error, never as a short result; the audit keeps only the error's class). Whatever ends a stream also closes the query, so its operators' state (a sort's runs, a hash table) is released then, not at the session's next statement. Each end is onedooreventstream_<outcome>with the rows that went out. In the other direction aDoPutingest whose client cancels or dies mid-stream is seen between batches and at the end of the stream (a dead client reads as a clean half-close to gRPC): the load fails asacl: the client cancelled the ingest, rolls back whole (nothing lands), its ingest event readsdenied/unavailable, and adooreventingest_cancelledsays which side stopped. - Sessions are cookie-identified connections (spec 050). The first call of a connection is
handed a session cookie (Arrow's
arrow_flight_session_id, minted by our CSPRNG); a client that returns it has a durable session backed by a duckdbConnectionthe door holds for the session's life. A cookie-less call gets a per-call session closed on the way out - single-call RPCs still work, session resources honestly refuse. The token is verified on every call before the cookie is honoured; a cookie presented with a different principal's token closes the old session and opens a fresh one. - Session temp tables:
CREATE TEMP TABLEunder the explicittempcapability of the MAIN catalog grant (never in the unstated default). They live in the held connection's private temp catalog, resolve by bare name (a virtual name always wins), appear in the session's ownSHOW TABLES/GetTables, and are reclaimed by duckdb when the session ends.adbc_ingest(..., temporary=True)stages into one; a cookie-less call is refused (lives in the session). - Transactions (spec 055):
BeginTransactionopens one on the session's connection and mints atransaction_id; statements carrying it are validated against the session's own (acl: unknown transaction - it belongs to no open transaction of this session (begin one, or omit the id for autocommit));EndTransactioncommits or rolls back. Needs a cookie session (acl: a transaction needs a connection-long session - echo the door's session cookie (a client has one from its second call on)); one at a time (acl: a transaction is already open on this session - commit or roll it back first (savepoints are a follow-up)). A session that ends mid-transaction is rolled back by connection teardown. Use either the protocol's actions or rawBEGIN/COMMIT, not both. - Session options (spec 068):
SetSessionOptions/GetSessionOptionsforTimeZoneandCalendar- see Client-local settings. - Auth discovery (spec 064, spec 095): a
Handshakewhose payload isdiscover-authanswers, without credentials, the issuers the node trusts (name and URL), the endpoints the IdP's own OIDC discovery names, and each issuer's clients a driver can run a flow as (name,client_id,flows). The same document the quack door serves at/.well-known/quack-auth. - The password handshake (spec 064): a
Handshakecarryingauthorization: Basic <user:password>becomes the OAuth password grant, run by the node as the one client of the node withFLOWS (password)- several are refused rather than tried in turn, so a password goes to one IdP - its client secret, if any, read from the secrets service for the call only (spec 095). The token the IdP answers is verified offline like any bearer and returned in the response headerauthorization: Bearer <token>, which stock JDBC, ADBC and pyarrow read. The node has no flow toggle - the IdP's refusal is the gate (acl: the IdP at <issuer> refused the password grant: ...). It needs a client with the password flow (acl: no client here runs the password flow (FLOWS (password)), so the door cannot run the password grant - authenticate with a bearer token instead) and a TLS door (acl: the password handshake needs a TLS door (acl_flight_serve with a certificate) - refused over cleartext). A payload-less, header-less handshake is a no-op success (spec 058) - the per-call bearer stays the only authority.
Refusals a client sees: acl: authentication failed for a missing, unverifiable or expired-at-open
token (and nothing more); acl: node is draining - not accepting new sessions (gRPC UNAVAILABLE)
for a new client on a draining node; the rewriter's own sentences for policy refusals (no access to object ..., ... does not satisfy the grant ..., EXPLAIN needs the explain capability). A duckdb
exception under any RPC is turned into a named status; Unexpected error in RPC handling never
appears (spec 046).
Clients: DBeaver / JDBC, ADBC (python),
Power BI / Fabric. The cookie middleware
(DatabaseOptions.WITH_COOKIE_MIDDLEWARE in ADBC) is what makes a connection one session.
What a tool reads (spec 115)
A database tool (DBeaver, DataGrip, an ADBC or JDBC driver) builds its tree and its SQL from the door's metadata, all of it answered for the connected principal:
- GetSqlInfo -
"as the identifier quote,catalog/schema/functionas the terms, the catalog at the start of a name,\as the search escape, transactions supported, and the reserved words (duckdb's and the management grammar's:ACL,NATIVE,VIRTUAL, …). - Type names - every field the door describes (GetTables
include_schema, a statement's result, a prepared statement's parameters) carries Flight SQL'sARROW:FLIGHT:SQL:TYPE_NAMEwith duckdb's type text (STRUCT(city VARCHAR),INTEGER[],DECIMAL(9,2)plus precision and scale) - what the role reads, a narrowed struct included. - GetXdbcTypeInfo - the system catalog's types (a principal names no other, spec 099).
- Functions - Flight SQL has no RPC for them; a tool reads
duckdb_functions()(every catalog the principal holds, a table function's result asTABLE(…)) andacl_function_columns()(parameters and result columns).duckdb_schemas()names each nested schema'sparent_schemaand lists a schema that holds only functions, so the tree can be built - every schema listing (information_schema.schemata, SHOW SCHEMAS, Flight GetDbSchemas) takes those nodes too. A table function's result columns are the ones the call returns to the role (a grant's hidden column is not listed). - Cost at scale (spec 119) - a listing reads the physical facts only of the sources it answers
about: the constant
=/IN/ wildcard-freeLIKEconditions on the catalog, schema and name columns of the WHERE above a metadata surface (what the Flight catalog RPCs and DBeaver write) choose which sources are read - the answer is the same with or without them. Expanding one schema or one table costs what it holds, whatever else is attached (measured: 200 sources x 150 tables - one schema's tables 0.04 s, one table's columns 0.2 s). A source whose catalog fails is left out of the listing instead of failing it - and says so (spec 136): an object over it stays listed ininformation_schema.tables(and so in Flight GetTables, without the comment) as aBASE TABLEwhose comment beginsacl: source <database> could not be read (<class>)- the exception's class (IO,Binder,not attached, ...), never its text; the database is named only to a principal that may read the object - with no physical columns;duckdb_tables()leaves a column-less object out (spec 065); an alias schema over it and an admin'splatform.attached.<alias>carry the same mark induckdb_schemas(). Only those surfaces read for the mark, and one statement reads a failing database once, however many schemas and subqueries. The node recordspolicy/source_unreadable(once per source and class a minute) and countsacl.listing.sources_unreadable{class}- a database that is not attached is marked, never counted. An answer aboveacl_metadata_max_rows(1,000,000 - a safety bound: a quack client loads a catalog whole) is refused, never cut:acl: more than N rows of <surface> - narrow by catalog, schema or name- a quack catalog load falls under it too. A session may lower the bound (SET acl_metadata_max_rows = 100); a value above the node's is refused, and the node's lower bound always holds (aRESETor the default written back changes nothing).
Administering through the door (spec 117)
An administrator connects like any client and manages the node without ACL NATIVE. The
platform catalog answers the catalog RPCs like any other catalog: GetCatalogs
lists platform to a principal holding a bundle, a catalog's manage or a point grant on it,
GetDbSchemas its schema main, GetTables its views (with include_schema, their typed columns) - and
only the objects that principal holds; a principal without administration sees no platform at all.
Its views are read with plain SELECT (SELECT * FROM platform.sessions), its operations are called at
the top level (SELECT platform.grant_catalog('analyst', 'sales', caps := ['select'])), and the
management grammar is written without the ACL marker (GRANT CATALOG sales TO ROLE analyst WITH (select)), ad-hoc or prepared; a prepared call may bind its payload arguments (comment := ?), never
the ones that carry authorization. A management statement or call is a change, not a query: like a
DML without RETURNING (spec 111) it runs at GetFlightInfo, its answer kept for the DoGet, so a client
that executes and never fetches (a DBAPI execute, ADBC) keeps the change. In
DBeaver or DataGrip over the JDBC driver platform is one more catalog in the tree, its views under
Views and its functions under Functions.
The quack door
SELECT acl_quack_serve('quack:localhost:31700', 'live-server-token'); -- embedded, discovery on, cleartext, loopback only
SELECT acl_quack_serve('quack:0.0.0.0:31700', token, '/etc/acl/cert.pem', '/etc/acl/key.pem'); -- embedded + TLS, any address
SELECT acl_quack_serve('quack:0.0.0.0:31700', token, 'plain'); -- bare quack server, cleartext, TLS terminated upstream
SELECT acl_quack_serve('quack:0.0.0.0:31700', token, cert, key, 'embedded'); -- the five-argument form
SELECT acl_quack_stop('quack:localhost:31700');
The door is quack's own server compiled into acl (spec 063, third_party/quack); it binds the public
address itself, terminates TLS, and speaks the quack protocol with no proxy in between. Argument
shapes: (uri, token), (uri, token, mode), (uri, token, cert, key), (uri, token, cert, key, mode); mode is 'embedded' (default) or 'plain'. The uri is quack's quack:<host>:<port>
(:0 picks a free port; the function returns the uri actually bound).
The platform catalog is absent on this door (spec 117): quack loads a
catalog whole at ATTACH, so platform is neither listed nor readable over a quack session; an
administrator here manages with the grammar (GRANT …, CREATE ROLE …, no ACL marker needed), and
reads the policy and the node through the Flight door.
- The server token is required, and it is quack's own shared token, not what admits a client -
the client's JWT is.
acl_quack_serve: pass a server token explicitly. It is not what admits a client - their JWT is - but a default-configured quack accepts whatever a caller sends, and this is the outer fence around that. - TLS takes cert and key together (
acl_quack_serve: TLS needs both the certificate and the key), each inline PEM or a location read through duckdb's filesystem, exactly as the Flight door. TLS needs a build that carries OpenSSL: `TLS needs a build that carries OpenSSL (the flight build)- this build serves cleartext only`. A remote quack client speaks https by default; a loopback one stays plain.
mode := 'plain'drops the discovery route for a bare, still acl-gated server. It is cleartext-only and is the explicit opt-out of the loopback rule for a deployment whose proxy terminates TLS; cert/key with it are refused: `acl_quack_serve: 'plain' mode is a cleartext server- terminate TLS upstream, or drop the mode to serve TLS here
. Anything else:acl_quack_serve: unknown mode "" (expected 'embedded' or 'plain')`.
- terminate TLS upstream, or drop the mode to serve TLS here
- Discovery:
GET /.well-known/quack-auth(http or https, per the door) answers the spec-064 document, composed from the live policy per request - an issuer added or dropped after the serve is advertised at once. While the node drains it answers 503 with bodydraining. ATYPE quack, PROVIDER oidcsecret whoseSCOPEnames the door may omitISSUERand read it from here (clients/quack.md). - Bind failures are named:
acl_quack_serve: a server is already listening on <host:port>,acl_quack_serve: failed to bind <uri> (address in use, permission denied, or invalid host/port). - Randomness: the server mints tokens and session ids from a crypto module. A Flight build serves
as is (acl registers an OpenSSL-backed
EncryptionUtilat serve time, only if none is set); a build without OpenSSL answersacl_quack_serve: the server needs a writable crypto module for its token/session RNG - `LOAD httpfs` (the usual choice, also used for JWKS) or `SET force_mbedtls_unsafe='true'` before serving. (...). - A build without the embed:
acl_quack_serve: this build was compiled without the embedded door (ACL_NO_QUACK_EMBED or WASM). - Types a client must bind (spec 099): a quack client builds its tables from the served DDL
(
duckdb_tables().sql) and reads the server's vectors as they come, so one type it does not know fails its whole ATTACH. An extension's alias type (MSSQL_VARCHAR(n)over an mssql source) is therefore served as its base type by default (acl_alias_types = 'base') - a client without mssql attaches and reads it; ENUMs are served as they are unlessacl_enum_types/SET TYPES (enums = varchar)says otherwise. The description and the stream always agree (management SQL: column types); the Flight door needs nothing - its Arrow schema is the result's (a kept ENUM goes out as a dictionary, a cast one as utf8).
How it enforces: quack calls acl_quack_authenticate(session_id, client_token, server_token) once
per connection (it opens a session with the client's JWT and binds it to quack's connection id; a
NULL or false answer is quack's own authentication refusal) and acl_quack_authorize(connection_id, query) per statement, whose VARCHAR return replaces the executed SQL - the prefixed statement, or
NULL, which quack turns into its refusal. Both are the defaults of acl_quack_authentication_function
/ acl_quack_authorization_function, so a plain serve needs no SET. quack's own functions
(quack_serve, quack_query, quack_active_connections, scan_data_from_quack_client,
acl_quack_scan_data, ...) are in the function gate's never set (spec 072): refused under a principal
whatever the policy says. Streamed bulk ingest (SEND_DATA) is
generated unprefixed by the server; the parser override recovers the principal from the stream id
(spec 042) and enforces the write as that principal's, and refuses where recovery fails.
Staging on quack is a granted schema (spec 056): a quack client's CREATE TEMP TABLE is its own
local catalog and an attached catalog cannot hold a temporary object, so the Flight door's session
temp is unreachable from here by construction. Grant a live schema alias select, insert, create, drop; the client creates a table in it, drains into it, promotes with plain SQL, drops it.
acl_quack_stop(uri) frees the port synchronously and returns a status string rather than an
error: Stopped listening on <uri> (N session(s) closed), or No server found listening on <uri> ... when nothing served that uri. The last-door rule: quack does not say which listener a
connection arrived at, so sessions are closed only when the instance has no quack door left; with
another still open the answer is Stopped listening on <uri> (another quack server is still open, so its sessions stay). A stop naming a uri nobody serves is the No server found note and nothing
else - nothing of ours closed, so nothing is swept (it used to run the last-door close anyway; fixed
2026-09-03). A door of another instance is refused: acl_quack_stop: the server on <uri> belongs to another database instance.
The embedded server's settings (registered with the door; UBIGINT unless noted):
| setting | default | scope | meaning |
|---|---|---|---|
acl_quack_authentication_function | acl_quack_authenticate | GLOBAL | callback the server SELECTs per connection |
acl_quack_authorization_function | acl_quack_authorize | GLOBAL | callback the server SELECTs per statement |
acl_quack_server_max_connections | 1024 | GLOBAL | max concurrent connections |
acl_quack_server_keep_alive_timeout | 300 | GLOBAL | seconds an idle keep-alive connection is kept |
acl_quack_cache_max_rows | 100000 | GLOBAL | rows retained in the result cache (0 = unlimited) |
acl_quack_result_ttl | 3600 | GLOBAL | seconds an idle cached result is kept (0 = never) |
acl_quack_prepare_inline_rows | 24576 | any | rows returned inline in a PREPARE response |
acl_quack_target_batch_bytes | 8388608 | any | target size of one wire batch (quack's own is 32 MiB; spec 063) |
acl_quack_rebalance_buffer_bytes | 0 | any | pending bytes before producers are gated |
acl_quack_fetch_producer_buffer_bytes | 268435456 | any | server-side fetch-ahead cap |
acl_quack_fetch_window | 8 | GLOBAL | batches with rows a client may have in flight when its result starts (0 = no window) |
acl_quack_fetch_window_max | 0 | GLOBAL | the most the window grows to while the client keeps reading (0 = no cap) |
acl_node_stream_budget | 0 | GLOBAL | bytes the node's producing quack streams reserve together (0 = half the memory limit) |
acl_quack_stream_reserve_bytes | 0 | GLOBAL | what one producing stream reserves (0 = producer buffer + window cap, or client depth, × batch) |
acl_stream_queue_timeout | 25 | GLOBAL | seconds a statement waits for room in the stream budget, then is refused (0 = at once) |
acl_quack_client_depth | 64 | GLOBAL | workers one quack client reserves (its read-ahead); the door seats acl_quack_server_max_connections / this many clients and refuses the next at connect (0 = no seat accounting) |
acl_quack_enable_reconnects (BOOLEAN) | false | any | keep the last result until acknowledged |
acl_quack_debug_emit_delay_ms | 0 | any | debug: random delay before a batch is published |
The fetch window (spec 077). A quack client keeps its whole read-ahead of FETCHes in flight
(64 on a 16-core client). When its query stops early, on a LIMIT or a cursor closed, it waits for
all of them before it cancels. Without a window, one LIMIT 1 over a large result makes the server
produce and send 64 batches.
The door answers a FETCH past the window with an empty batch: a chunk of zero rows that the client
skips. The produced batches move to the indices after it, so the client still reads every row once,
in order. The window starts at acl_quack_fetch_window and doubles each time the client acknowledges
a full window of batches. It stops at acl_quack_fetch_window_max, or keeps growing when that is 0.
So an early stop costs the window a result starts with, while a long read reaches the client's full
parallelism within a few windows.
- A fixed window. Set both settings to the same value to hold a client to that many batches in flight for the whole result. Below the client's cores, a full read slows down, because the client decodes one batch per thread.
- No window.
acl_quack_fetch_window = 0is quack's own behaviour.
An empty answer waits while a batch with rows below it is still on its way: first until it is produced, then until the client acknowledges it or 20 ms pass. Without that wait, the client's scan threads would spin on empty answers for as long as a slow batch takes.
The window works within the protocol and needs nothing from the client. Measurements are in spec 077.
Seats and the load report (spec 079). The door keeps a worker thread per keep-alive connection,
and a quack client keeps one connection per FETCH in flight, which is its read-ahead: 64 by default.
So the door seats acl_quack_server_max_connections / acl_quack_client_depth clients, 16 by default.
The next client is refused at connect with the reason: acl: node at capacity - 16 of 16 quack clients seated (...); try another node. A client that disconnects, or whose heartbeat lease runs
out, frees its seat, and its session ends with it.
Clients that set quack_fetch_read_ahead = 8 let a node seat eight times as many. Setting
acl_quack_client_depth = 8 to match makes the door count them that way.
acl_node_load() answers the numbers admission decides with: sessions against acl_max_sessions,
each quack door's seated clients against its seats, draining, the node's resource group (spec 096),
and whether a new session or quack client would be admitted. An orchestrator reads the same document in two places while
acl_metrics_endpoint is on:
GET /.well-known/acl-nodeon the quack listener;- the Flight Handshake payload
node-load.
Who may read it (spec 097). Both places - and GET /metrics - answer only a bearer token whose
roles hold the observe scope (GRANT ADMIN observe TO ROLE r), or passthrough / an
unrestricted manage, which imply it. observe reads; it administers nothing. The token travels as
Authorization: Bearer <jwt> (on the Flight Handshake: its authorization header, Bearer or the
bare token) and is verified like a session's, offline; no session is opened. The answers: 404 while
acl_metrics_endpoint is off (Flight NotImplemented), 401 with WWW-Authenticate: Bearer for no
token or one that does not verify (Flight Unauthenticated), 403 for a principal without the scope
(Flight Unauthorized, gRPC PERMISSION_DENIED), 503 when the node cannot decide - its policy source or
keys do not answer, or its identity policy cannot be applied (Flight Unavailable). A refusal says no more than its status; the audit records it
(a door event observe_<surface>), and every read is counted (acl.door.observe{door,surface,result}).
Like every privileged role, an observe role is reached only through a client's own mapping
(MAP CLAIM … FROM CLIENT c TO ROLE r). For a standalone node scraped by a Prometheus without
credentials, SET GLOBAL acl_observe_unauthenticated = true opens all three to anyone who reaches
the port; the report says which mode is in force ("observe": "token" | "open"). The setting is the
node's - never a cluster profile item.
# prometheus.yml - a scrape with the observer's token
scrape_configs:
- job_name: acl-node
metrics_path: /metrics
authorization:
type: Bearer
credentials_file: /var/run/secrets/acl-observer-token
static_configs:
- targets: ["node-1:8815"]
The stream budget (spec 080). A producing quack statement reserves the memory its stream can
hold, its producer buffer plus its batches in flight, against acl_node_stream_budget. That budget
defaults to half the memory limit. A statement that finds the budget full waits on the node, in
arrival order, for up to acl_stream_queue_timeout. It then fails with the reason: acl: node at capacity - the stream memory budget stayed full .... The load report's streams section and
admit.new_stream show the budget, and so do the acl.streams.* gauges.
Resource groups (spec 085). A group is a set of named limits, bound to roles (management-sql.md).
- When it applies. A session takes its groups' limits when it opens and keeps them until it ends. A grant or a drop applies to the sessions opened after it.
- quack. A group's
window_start/window_maxshape the fetch window, andbatch_bytesis the batch target on the door's connection. A statement's stream-budget reservation is priced by those limits, and it waits in the budget's line byqueue_priority. A waiting statement gains one level per 5 s, so nothing waits past the timeout because of priority alone. - Flight.
max_result_rowscaps the session's streams. - Everywhere.
max_sessionsrefuses a new session of the group at open, withat_capacity. - In the load report.
sessions.by_groupcounts the group's live sessions against its max. That names the groups to whoever may read the report, so do not name a group after anything secret. - Placement (spec 096 - a platform node's: the node's group is set by hugr-node;
an open node has none and serves everyone). A node set to a group with
acl_node_groupserves only principals that hold the group (or, bound to none, the default group's members), with that group's limits only. Anyone else gets a refusal a front acts on: Flight answersUNAVAILABLEwithacl: this node serves resource group "g" - your roles are served by "h", quack refuses the connect (its client shows the same sentence inside its authentication error), and the session event'sreason_codeiswrong_resource_group. The load report saysgroup,group_known(a group the policy does not have admits nobody:admit.new_sessionis false) andconfig- the profile version the node targets and the one its agent reported applied. A node without a group serves everyone, exactly as before.
Client recipe: clients/quack.md (ATTACH 'quack:<host>:<port>' AS remote (TYPE quack, TOKEN '<token>'), or a secret).
Sessions
A session is opened when a token is verified - by a door or by acl_session_open - and ends when:
- it is closed (
acl_session_close, the driver'sCloseSession, quack re-authenticating the same connection, a door stopping); - it goes idle for
acl_session_idle_timeoutseconds (default 900); - its token's
exppasses - only underacl_session_token_binding='every_use'. Under the defaultconnect,expis judged at establishment only: an expired token opens nothing, but an open session keeps working until idle, close or kill (spec 059; the trade is spelled out in authentication.md); - an operator kills it (
acl_session_kill).
Dead records are swept in one pass: explicitly by acl_session_sweep(), automatically inside
SessionOpen at most once every 60 seconds or whenever the map is at acl_max_sessions. At the cap
a new session is refused, never an old one evicted - an arriving stranger must not be able to end
somebody's session. On the Flight door each session holds a duckdb connection, so the cap bounds held
connections too.
The operator's surface, on the node's own connection (or through a passthrough admin's ACL NATIVE):
SELECT acl_sessions(); -- JSON: [{"id":"...","subject":"...","roles":[...],"idle_seconds":N,"expires_at":N}, ...]
SELECT acl_session_kill('<id>'); -- BOOLEAN: true if a session with that ops id was ended
SELECT acl_session_count(); -- BIGINT: sessions live right now
SELECT acl_session_sweep(); -- BIGINT: dead records removed
SELECT acl_session_reason('<handle>'); -- 'live' | 'expired' | 'idle' | 'unknown', read-only
acl_sessions()never shows the handle (a bearer credential); theidis a separate non-secret ops id. It lists the records on the node; a record judged dead but not yet swept can still appear, soacl_session_sweep()first gives the exact live set.expires_atis the token'sexp, raw - underconnecta session may legitimately outlive it.acl_session_kill(id)ends the session and its bindings; on the Flight door the held connection (temp tables, an open transaction) is reclaimed by the door's sweep, and the connection's next call is a new session - refused while draining.acl_session_reason(handle)is for the component that holds the contract: a NULL fromacl_session_sqlfollowed byexpiredmeans "fetch a fresh token",idle/unknownmean "reopen with the same one" (closed and never-existed both readunknown).- Every one of these, the contract functions and the serve/stop functions are denied to a
principal (
function "acl_session_open" is not allowed, and likewise for each): a client can neither mint a session, compose a prefix, read who else is connected, nor drain the node. A client writingACL SESSION '...'into its own query text does not become another principal; a dead handle in the prefix is refused asacl_rewrite: session unknown|expired|idle.
Settings (all GLOBAL - the store reads them through the instance, so a session-scoped SET would
report success and change nothing):
| setting | default | meaning |
|---|---|---|
acl_session_idle_timeout | 900 | seconds a session may go unused before it is dead; 0 disables the rule - refused while the binding is connect (acl_session_idle_timeout=0 would leave no automatic session reaper under acl_session_token_binding='connect' - set the binding to 'every_use' first (spec 059)) |
acl_session_token_binding | connect | connect judges exp at establishment only; every_use re-judges it on every use. GLOBAL-only (acl_session_token_binding is global - use SET GLOBAL); other values refused (acl_session_token_binding accepts 'connect' or 'every_use', not '<v>'); entering connect with idle at 0 is refused (acl_session_token_binding='connect' needs a live idle reaper: set acl_session_idle_timeout > 0 first (it is currently 0/disabled)). Memory mode (no policy catalog) stays at every_use |
acl_max_sessions | 1000 | sessions that may live at once; at the cap a new one is refused; 0 = unlimited |
acl_max_ingest_rows | 0 | rows one Flight ingest may stream; 0 = unlimited |
acl_metadata_max_rows | 1000000 | rows a metadata surface (information_schema, duckdb_*, SHOW, the Flight catalog RPCs, quack's catalog load) answers a principal; above it the statement is refused, never cut; 0 = unlimited; a session may lower it for itself, never raise it (spec 119) |
acl_max_result_rows | 0 | rows one Flight statement may hand out: exactly that many go out, then the stream ends in a refusal; 0 = unlimited (spec 070) |
acl_flight_stream_idle | 30 | seconds a Flight result stream may sit unpulled (since its last pull returned) before the session's next statement ends it and runs; a stream being pulled is never ended - the statement waits; 0 = never - the statement waits for the stream's own end (spec 070) |
acl_jwt_clock_skew | 60 | seconds of skew allowed on JWT exp/nbf (GLOBAL since the 2026-09-03 review) |
acl_version_check_interval | 1000 | milliseconds between policy_version re-reads of the policy catalog (0 = every batch) |
acl_jwks_refresh_interval | 300 | seconds a fetched JWKS is used before it is read again |
acl_jwks_max_stale | 3600 | seconds a JWKS that can no longer be read may still be used; 0 = a failed read is fatal at once |
acl_jwks_locations | https:// | prefixes every location the node reads an issuer's keys from may start with (its discovery, the JWKS, a secret's KEYS_FROM); '' admits none (specs 071, 095) |
acl_allow_anonymous_admin | false | a bare ACL ADMIN with no principal; must be off to serve |
Graceful shutdown (spec 066)
A serving node can be put into drain: it stops seating new clients while every established session keeps working, so in-flight statements, transactions, ingests and fetch streams finish naturally.
SELECT acl_drain(); -- enter drain; sweeps, then returns the live-session count (BIGINT)
SELECT acl_drain_status(); -- 'serving' | 'draining'
SELECT acl_resume(); -- leave drain; true if it was draining, false if it already served
acl_drain() is idempotent and sweeps before counting: the automatic sweep rides SessionOpen,
which drain turns off, so the drain call pays instead and the count it returns is what genuinely
remains. Repeating acl_drain() is the watch loop.
What a draining node refuses - every path that turns a stranger into a session goes through one flag, so a door that forgot to check still could not seat anyone:
- Flight: a new client, and a seated connection re-authenticating as a different principal, get
acl: node is draining - not accepting new sessions(UNAVAILABLE - a load balancer and a driver both read it as "go elsewhere"). A swap opens the new session before it ends the old one, so a refusal - this one or any other - never ends a session. The password handshake is refused the same way. - quack: the authentication callback answers NULL, which quack turns into its own refusal;
GET /.well-known/quack-authanswers 503draining- the health-check shape a load balancer already watches. - The contract:
acl_session_openreturns NULL.
What keeps working, deliberately: statements, BEGIN, ingest, reservations and stream drains on
established sessions; ACL TOKEN/ACL ROLE prefixed statements (in the gateway deployment the
gateway is what drains; refusing its statements mid-flight would turn a graceful stop into an outage);
policy administration; acl_sessions(), acl_session_kill, the serve/stop functions - draining is
when the ops surface is needed most.
The supported stop sequence:
acl_drain() -- new clients now land elsewhere
repeat acl_drain() until 0 -- the watch loop, or the orchestrator's deadline
acl_session_kill('<id>') ... -- stragglers, if the deadline came first
acl_flight_stop(uri) / acl_quack_stop(uri) -- synchronous teardown
close duckdb / terminate the process
Stopping a door closes every session of the instance, not only the ones that door served -
sessions are not attributed to a door - so with both doors open, stop them together at the end of
the drain, not one of them mid-way. Stop the doors before closing duckdb: a Flight door left
listening on an instance that closed is a follow-up, not a supported state (a later serve on that
uri reclaims it; terminating the whole process is always safe). The node never times out by itself: the deadline belongs to the
orchestrator that is stopping it (terminationGracePeriod, TimeoutStopSec, a script), which
already has one - a second timer inside the node would add a race, not safety. Drain is runtime
state, not configuration: a restarted node serves; a node that must come up draining calls
acl_drain() in its init script before serving. All three functions are denied to a principal.
Client-local settings (spec 068)
SET is refused under a principal except for the two ICU rendering settings, TimeZone and
Calendar - a TIMESTAMPTZ rendered in the server's zone is a wrong answer, not a cosmetic one - the
trace and lineage context settings, and acl_metadata_max_rows (spec 119: lowered only) - and
only where the principal owns its connection. The gate checks four things, and the refusal says which
failed:
- the name is on the allowlist (
SET,RESETandSET VARIABLEalike):SET "<name>" is not permitted under ACL: only the client-local rendering settings (TimeZone, Calendar) may be set, and only on a session of the client's own; - the scope is not GLOBAL:
SET GLOBAL "<name>" is not permitted under ACL: a global setting changes the node for every principal - set it for the session; - the statement arrived through
ACL SESSION- a door's client whose session is a connection:SET "<name>" needs a session of the client's own (a door's ACL SESSION): a per-statement prefix runs on a connection the gateway shares, where a setting would leak to the next principal; - the value is a constant:
SET "<name>" takes a constant value under ACL.
The gateway path may never set one: a gateway shares connections between principals, and a
per-statement SET is exactly how one principal's time zone becomes the next principal's wrong
answer. A gateway that wants per-client settings gives its clients sessions.
On the Flight door the protocol's SetSessionOptions lands on the same allowlist and the same session
connection (ADBC: adbc.flight.sql.session.option.TimeZone); an unlisted name answers
kInvalidName, a non-string value or a value duckdb rejects kInvalidValue, and it needs a cookie
session (acl: a session option needs a connection-long session - echo the door's session cookie (a client has one from its second call on)). GetSessionOptions reads the two back on the session's
connection - the door's read; current_setting stays denied to principals. On quack a client's
SET TimeZone = 'Asia/Tokyo' is plain SQL through acl_quack_authorize and the gate is the whole
mechanism. duckdb autoloads icu for the settings themselves.
The session's catalog and schema (spec 114)
A short name (orders, dbt_home.m) resolves in the role's MAIN catalog. A client chooses another for
its session with plain SQL:
USE mart; -- the default catalog: `facts` is mart.facts, `out.t` is mart.out.t
USE SCHEMA out; -- the default schema in it: a bare `t` is mart.out.t, the root is main.<x>
USE mart.out; -- both at once (duckdb's own form)
USE SCHEMA out.deep; -- a nested schema by its parts (`USE mart.out.deep` too); "out.deep" quoted whole is
-- another, one-part schema whose name holds a dot
USE SCHEMA main; -- back to the catalog's root
USE sales; -- the role's MAIN catalog: back where the session began
- Only on a session of the client's own (
ACL SESSION, a door's client) - a gateway's per-statement prefix is refused, as aSETis - and only a catalog or schema the principal holds a grant on; it grants nothing, a short name reaches what the full one does. A name whose first part is a catalog the principal holds stays that catalog's; if that part is also a schema of the session's catalog, the statement is refused as ambiguous. - It is kept on the session, so every door carries it: on Flight
USEis a statement like any other (it runs atGetFlightInfo, so ADBC's unfetchedexecutekeeps it); on quack a plainUSEis the client's own catalog - send it to the node withFROM quack_query_by_name('<alias>', 'USE mart'). current_database()/current_schema()answer the session's catalog and schema; a bareSHOW TABLESlists the session's schema.information_schema.*still lists every catalog you hold.- A
USEin a batch applies to the statements after it;USE SCHEMA s(and aUSEof three parts or more - a nested schema) must be a request of its own. AUSEis not undone byROLLBACK. - A schema is
USEd only when you hold a grant on it (GRANT SCHEMA); function names still resolve in the MAIN catalog. UnderUSE <catalog>.<schema alias>a session temp table istemp.main.<name>- the alias claims every bare name in it.
Audit and metrics (spec 069)
Every decision the node makes about a principal is one event: a statement admitted or refused
(with the objects it touched and the capability judged for each), a management statement, a session
opened / closed / refused / moved / refused a move (spec 135: the items it carried or lost, by name - never a value,
reason codes session_pinned, session_unrestorable, session_unreadable), an ingest completed with its row count, a Flight ticket issued / redeemed /
expired / presented by somebody else, a Flight result stream ended (consumed, cancelled, superseded,
capped, failed - with the rows that went out) or an ingest its client cancelled, the password
handshake, a policy reload or source error, a source a listing could not read (source_unreadable, spec 136), a
keys refresh. An event carries who (subject, roles - never a claim value), where (door, the session's ops
id - never the handle), what (the statement class - never the text), the verdict, and on a refusal
one reason_code from a bounded taxonomy (no_access, capability, read_only, function_denied,
statement_type, unchecked_predicate, setting_denied, parse, principal,
mgmt_unauthorized, ddl_home, draining, at_capacity, wrong_resource_group, source_error,
unavailable, write_policy, policy_error) plus the refusal text.
- Levels -
SET GLOBAL acl_audit_level = 'off' | 'denied' | 'decisions' | 'all'(defaultdecisions): refusals only; every statement/admin/ingest decision; plus the session, door, policy and keys lifecycle.acl_session_audit_level(<ops id>, <level>)overrides it for one live session (the operator's call, never the principal's). Whatever the level, the counters count: metrics are a state of the node, audit is a record of it. - Profiles (spec 074) -
SET GLOBAL acl_profile_level = 'off' | 'sampled' | 'all'(defaultoff): oneprofileevent per decided statement executed, with the time, rows, bytes, memory, the per-source rollup and the plan - never the statement's text.acl_session_profile(<ops id>, <level>)or the management formPROFILE SESSION CURRENT | '<ops id>' ON | SAMPLED | OFFoverrides it for one live session from its next statement;SET SESSION acl_profile_leveldoes it for an operator's own connection;acl_sessions()shows the level in force and its source. - Where it goes - a ring of the last
acl_audit_bufferevents (default 10000), read withacl_audit_events(); optionally one JSON line per event appended toacl_audit_sink(a path or URI through duckdb's filesystem, so an object store rides httpfs); and any sink an extension registers throughacl_audit.hpp(theacl_otelextension ships OTel logs and metrics; loaded besideacl, either order). Delivery is off the decision path: a bounded queue (acl_audit_queue) and one audit thread; when a sink is slow the queue drops and counts (acl_audit_dropped(),acl.audit.dropped) - a statement is never slowed by its own audit. - Metrics -
acl_metrics()answers every counter and gauge with its attributes as JSON:acl.decisions{verdict,kind,door,statement},acl.denials{reason_code,door},acl.sessions.opened/refused/closed,acl.sessions.moved/move_refused(spec 135),acl.sessions.live,acl.door.handshakes,acl.door.tickets{outcome},acl.door.streams{door,outcome}(result streams ended, by outcome, plusingest_cancelled- the stream in the other direction) and the gaugeacl.door.streams_open{door}(result streams pulling right now) - spec 070,acl.ingest.statements,acl.admin.statements{scope},acl.policy.version/staleness/reloads/writes/source_errors,acl.listing.sources_unreadable{class}(a source schema a listing could not read, by the exception's class - spec 136),acl.jwks.refreshes/age,acl.audit.events/dropped/sink_errors/queue_fill/ring_fill,acl.node.draining/uptime/info. Attribute values come from bounded sets only - never a role, a subject or an object name.acl.door.observe{door,surface,result}(reads of the load report and the metrics, spec 097).acl.lineage.events{event_type},acl.lineage.approximate,acl.lineage.dropped,acl.lineage.errors,acl.lineage.parent_invalid{door}(see Lineage).SET GLOBAL acl_metrics_endpoint = trueaddsGET /metrics(Prometheus text) to the quack door's listener, for a bearer token holdingobserve(spec 097; see the load report above); 404 while off. A node runningacl_otelexports the same counters and gauges over OTLP and needs neither. - Tracing - a statement's trace rides in the prefix as
TRACE '<correlation id>'andPARENT '<W3C traceparent>', written by whoever composes it: a gateway callingacl_session_sqlsetsacl_correlation_id/acl_traceparenton its connection first; a quack client SETs the same two on its own session (spec 068's allowlist - the value lands on the session's record, since the server composes on a connection of its own); a Flight client sends thex-correlation-idandtraceparentheaders, or sets them throughSetSessionOptions. Every event of that statement carries both. - Node identity -
acl_node_id(default<hostname>:<pid>) is on every event and metric row, so a fleet's streams merge without ambiguity. Nodes never share audit state; a central view is the collector's job (spec 069, "In a fleet"). The routing name a front reads,acl_node_label, is a separate setting (spec 134; see deployment.md). - What a refusal's text may carry - our own message, which names virtual objects and nothing
else, with three exceptions the audit closes itself: a
parserefusal is a fixed sentence (the parser would echo the statement's text, and a literal in it could be a secret); aprincipalrefusal has its quoted values blanked (the issuer or algorithm of a token nobody verified is the caller's to make up); aningestfailure that is the physical source's keeps only the error's class (acl: the source refused the write (Constraint Error)), never the row it refused. Reasons are cut to 512 bytes, trace ids to 128. - One source cannot drown the others - at most
acl_audit_denials_per_second(default 100) refusals per second per source (a session, else a principal, else a door) are recorded; the rest are counted exactly like the recorded ones (acl.denialsstays true) and show asacl.audit.dropped{where=rate_limit}. Allowed decisions are not limited.0disables the limit. - Each trace marker once - a second
TRACEorPARENTwritten in the SQL text behind a door's prefix is a parse refusal, not a replacement of the one the door composed.
The whole surface - acl_audit_events(), acl_metrics(), acl_session_audit_level,
acl_session_profile, acl_audit_flush, acl_audit_dropped, the settings - is the operator's:
denied to a principal.
Deployment invariants and hardening checklist
- One of two shapes: gateway-only, or doors. In the gateway deployment only the gateway connects to DuckDB. With doors, clients connect to the node and every door composes the prefix itself; a client never writes one.
- STRICT stays on. The extension sets
allow_parser_override_extension='STRICT'on load; a served instance refuses to start under anything else, because underDEFAULT/FALLBACKa statement that fails to parse as ACL runs as plain SQL with the operator's rights. - Anonymous admin off before serving.
acl_allow_anonymous_admin=trueis the bootstrap's escape hatch; end the admin block withSET GLOBAL acl_allow_anonymous_admin=false;and only then serve. - TLS on a non-local address. A cleartext door binds loopback only. To leave the machine, pass a
cert and key (either door) or terminate TLS upstream and use quack's
'plain'mode. The password handshake exists only on a TLS Flight door. - The control functions are
acl_-prefixed and denied to principals.acl_*_serve/_stop, the session contract, the ops listing and kill, drain/resume - a client cannot reach any of them. The operator's path over a door is apassthroughadmin'sACL NATIVE <sql>(spec 009); amanageadmin administers policy throughACL <mgmt>but does not leave the virtual catalog. - Node-local state. Sessions, reservations, drain state and the door registries live in this
process (registries per process, sessions per
DatabaseInstance). There is no shared session store: behind a load balancer, stickiness is the front's cookie, and each node drains on its own. - Bound the sessions. Keep the idle reaper on (
acl_session_idle_timeout> 0 is mandatory underconnect), sizeacl_max_sessionsfor held connections, and know that the cap refuses rather than evicts - an authenticated attacker can fill it until idle drains it. - Choose the token binding knowingly.
connect(default) means disabling a user at the IdP ends new sessions, not open ones;every_useends them at the next statement at the price of long tokens or reconnects. - Explicit capabilities only where meant.
temp(session temp tables),explain(a plan names physical objects),create/drop(ingest create/replace, staging),secrets(the attached secrets service, spec 082) are never in the unstated default; grant them by name. - Stop before close. Drain, watch, kill stragglers, stop the doors, then close duckdb.
- A ready node to look at:
test/live/serve.sh [flight|quack|all] [--tls]seeds a demo policy and prints URIs and tokens;test/live/RUNBOOK.mdwalks DBeaver, ADBC and a quack client through it (make serve-flight/serve-quack/serve-live). It is a validation rig, not a deployment template - its issuer is a fixture undertest/idp/signed with a key committed to the repository.
Troubleshooting
| you see | it means |
|---|---|
<fn>: no policy source is configured ... | serve before acl_use_db(...) (or the function driver); configure the policy source first |
<fn>: `acl_allow_anonymous_admin` is on ... | the bootstrap escape hatch is still open; SET GLOBAL acl_allow_anonymous_admin=false |
<fn>: the parser override is "fallback", not STRICT ... | someone set allow_parser_override_extension; SET GLOBAL allow_parser_override_extension='strict' |
<fn>: without a TLS certificate the door serves in the clear, so it binds only localhost ... | a non-loopback uri with no cert/key; pass both, or (quack) 'plain' behind a TLS proxy |
acl_flight_serve: TLS needs both a certificate and a key ... / acl_quack_serve: TLS needs both the certificate and the key | one of the two was NULL or empty |
... the certificate location "<loc>" holds no single document / ... at "<loc>" is not PEM ... | the path is wrong, or points at a non-PEM file |
acl_flight_serve: a door is already listening on <uri> / acl_quack_serve: a server is already listening on <host:port> | this process already serves that uri; stop it first |
acl_quack_serve: pass a server token explicitly ... | the second argument is empty |
acl_quack_serve: 'plain' mode is a cleartext server ... | cert/key together with 'plain'; drop the mode to serve TLS here |
acl_quack_serve: the server needs a writable crypto module ... | a build without OpenSSL; LOAD httpfs before serving |
acl_*_stop: ... belongs to another database instance | two instances in one process; stop it from the instance that opened it |
acl_flight_stop: no door of ours is listening on <uri> / No server found listening on <uri> | nothing served on that uri (the uri must match what was served); the quack form is a status, not an error, and closes nothing |
client: acl: authentication failed | no authorization: Bearer header, a token that does not verify (issuer, signature, audience, nbf), or an expired token at session open; the door says no more by design |
client: acl: node is draining - not accepting new sessions / quack discovery 503 draining | the node is in drain; connect to another node, or acl_resume() |
client: acl: the password handshake needs a TLS door ... | BasicAuth on a cleartext Flight door |
client: acl: no client here runs the password flow ... | no client has FLOWS (password) and a client id; add one, or use a bearer |
client: acl: several clients run the password flow (...) | the door cannot tell whose IdP a password is for; keep the flow on one client |
client: acl: the IdP at <issuer> refused the password grant: ... | the IdP said no (wrong password, or ROPC disabled there) |
client: acl: unknown transaction ... | a stale or foreign transaction_id; begin one, or omit it |
client: acl: a transaction needs a connection-long session ... / acl: a session option needs a connection-long session ... | the client does not echo the session cookie (ADBC: enable the cookie middleware) |
client: no access to object ... | a physical name, a name outside the grant, or another session's temp table |
client: acl: the session moved to another node and lost its ... / ... could not restore its ... | the client's node went away (or its session died there) while the session held a transaction, temp tables or the view-as mode, or its USE / a setting no longer passes; the statement did not run, the next one works on a new session (spec 135) |
quack client: acl: this connection is not known on this node - connect again / acl: this connection's lease ran out on this node - connect again | the connection's node restarted, another node got the request, or its heartbeat lease ran out; ATTACH again (hugr_node's federation client reconnects by itself) |
client: acl: this connection's session viewed the catalog as a role and is gone - connect again | a session in the view-as mode ended (here, or on the node it moved from) and the client only SET the mode; declare it with x-acl-view-as, or open a new connection |
client: EXPLAIN needs the explain capability | grant explain on the MAIN catalog grant, by name |
client: ... is not permitted under ACL: only the client-local rendering settings ... | SET of anything but TimeZone/Calendar |
client: SET "<name>" needs a session of the client's own ... | a SET through a gateway prefix; give the client a session |
function "acl_session_open" is not allowed (any acl_* control function) | called under a principal; these are the node's, use the node's own connection or ACL NATIVE as a passthrough admin |
| `acl_rewrite: session unknown | expired |
acl_session_idle_timeout=0 would leave no automatic session reaper ... | switch the binding to every_use first, or keep idle > 0 |
acl_session_token_binding is global - use SET GLOBAL | the setting is GLOBAL-only |